diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 914e8088f3e55e..5baea3ba65fdcb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -248,6 +248,7 @@ pnpm-lock.yaml @getsentry/owners-js-de /src/sentry/snuba/subscriptions.py @getsentry/alerts-notifications /src/sentry/snuba/tasks.py @getsentry/alerts-notifications /tests/snuba/incidents/ @getsentry/alerts-notifications +/src/sentry/rules/ @getsentry/alerts-notifications /tests/sentry/rules/ @getsentry/alerts-notifications /tests/sentry/snuba/test_query_subscription_consumer.py @getsentry/alerts-notifications /tests/sentry/snuba/test_subscriptions.py @getsentry/alerts-notifications @@ -609,6 +610,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/gettingStartedDocs/ @getsentry/value-discovery /static/app/types/project.tsx @getsentry/value-discovery /static/app/views/onboarding/ @getsentry/value-discovery +/tests/acceptance/test_scm_onboarding.py @getsentry/value-discovery /tests/js/fixtures/detectedPlatform.ts @getsentry/value-discovery /static/app/views/projectInstall/ @getsentry/value-discovery /src/sentry/onboarding_tasks/ @getsentry/value-discovery @@ -643,7 +645,6 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /src/sentry/api/helpers/group_index/ @getsentry/issue-workflow /src/sentry/api/helpers/source_map_helper.py @getsentry/issue-workflow /src/sentry/api/endpoints/ @getsentry/issue-workflow -/src/sentry/rules/ @getsentry/issue-detection-backend /src/sentry/processing_errors/ @getsentry/issue-detection-backend /src/sentry/api/helpers/group_index/delete.py @getsentry/issue-detection-backend /src/sentry/deletions/defaults/group.py @getsentry/issue-detection-backend diff --git a/.github/workflows/backend-selective.yml b/.github/workflows/backend-selective.yml deleted file mode 100644 index 88a1ae68ed8020..00000000000000 --- a/.github/workflows/backend-selective.yml +++ /dev/null @@ -1,277 +0,0 @@ -# Parallel validation workflow: runs sentry backend tests using getsentry's -# cross-repo coverage DB for selective test selection. Runs alongside the -# existing backend.yml — not required for merge. -# -# Once validated, this will replace prepare-selective-tests in backend.yml. -name: backend (NOT REQUIRED - selective via getsentry) - -on: - pull_request: - types: [opened, synchronize, reopened, labeled] - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -defaults: - run: - shell: bash -euo pipefail {0} - -# hack for https://github.com/actions/cache/issues/810#issuecomment-1222550359 -env: - SEGMENT_DOWNLOAD_TIMEOUT_MINS: 3 - SNUBA_NO_WORKERS: 1 - SENTRY_SKIP_SELENIUM_PLUGIN: '1' - -permissions: - contents: read - id-token: write - actions: read - -jobs: - files-changed: - name: detect what files changed - runs-on: ubuntu-24.04 - timeout-minutes: 3 - outputs: - backend: ${{ steps.changes.outputs.backend_all_without_acceptance }} - skip_selective_testing: "${{ contains(github.event.pull_request.labels.*.name, 'Trigger: Override Selective Testing') }}" - steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Check for backend file changes - uses: dorny/paths-filter@0bc4621a3135347011ad047f9ecf449bf72ce2bd # v3.0.0 - id: changes - with: - token: ${{ github.token }} - filters: .github/file-filters.yml - - select-tests: - if: >- - needs.files-changed.outputs.backend == 'true' && - needs.files-changed.outputs.skip_selective_testing != 'true' && - github.event.pull_request.head.repo.full_name == github.repository - needs: files-changed - name: select tests via getsentry coverage - runs-on: ubuntu-24.04 - timeout-minutes: 10 - outputs: - has-selected-tests: ${{ steps.compute-tests.outputs.has-selected-tests }} - test-count: ${{ steps.compute-tests.outputs.test-count }} - steps: - - name: Get changed files - id: changed - env: - GH_TOKEN: ${{ github.token }} - run: | - - CHANGED_FILES=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files --paginate --jq '.[].filename' | tr '\n' ' ') - echo "Changed files: $CHANGED_FILES" - echo "files=$CHANGED_FILES" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version-file: '.python-version' - - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.3 - with: - project_id: sentry-dev-tooling - workload_identity_provider: ${{ secrets.SENTRY_GCP_DEV_WORKLOAD_IDENTITY_POOL }} - service_account: ${{ secrets.COLLECT_TEST_DATA_SERVICE_ACCOUNT_EMAIL }} - - - name: Download coverage database - id: download-coverage - run: | - - mkdir -p .artifacts/coverage - - GCS_PATH="gs://getsentry-coverage-data/latest/.coverage.combined" - echo "Fetching coverage DB from: $GCS_PATH" - gcloud storage ls -l "$GCS_PATH" 2>/dev/null || true - - if ! gcloud storage cp "$GCS_PATH" \ - .artifacts/coverage/.coverage.combined 2>/dev/null; then - echo "Warning: Failed to download coverage from GCS, will run full test suite" - echo "coverage-file=" >> "$GITHUB_OUTPUT" - else - ls -lh .artifacts/coverage/.coverage.combined - echo "coverage-file=.artifacts/coverage/.coverage.combined" >> "$GITHUB_OUTPUT" - fi - - - name: Compute selected tests - id: compute-tests - if: steps.download-coverage.outputs.coverage-file != '' - env: - COVERAGE_DB: ${{ steps.download-coverage.outputs.coverage-file }} - CHANGED_FILES: ${{ steps.changed.outputs.files }} - run: | - - - python3 .github/workflows/scripts/compute-sentry-selected-tests.py \ - --coverage-db "$COVERAGE_DB" \ - --changed-files "$CHANGED_FILES" \ - --output .artifacts/selected-tests.txt \ - --github-output - - - name: Upload selected tests artifact - if: steps.compute-tests.outputs.has-selected-tests == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: selected-tests-${{ github.run_id }} - path: .artifacts/selected-tests.txt - retention-days: 1 - - calculate-shards: - if: >- - needs.files-changed.outputs.backend == 'true' && - needs.files-changed.outputs.skip_selective_testing != 'true' - needs: [files-changed, select-tests] - name: calculate test shards (selective) - runs-on: ubuntu-24.04 - timeout-minutes: 5 - outputs: - shard-count: ${{ steps.calculate-shards.outputs.shard-count }} - shard-indices: ${{ steps.calculate-shards.outputs.shard-indices }} - steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Setup sentry env - uses: ./.github/actions/setup-sentry - id: setup - with: - mode: backend-ci - skip-devservices: true - - - name: Download selected tests artifact - if: needs.select-tests.outputs.has-selected-tests == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: selected-tests-${{ github.run_id }} - path: .artifacts/ - - - name: Calculate test shards - id: calculate-shards - env: - SELECTED_TESTS_FILE: ${{ needs.select-tests.outputs.has-selected-tests == 'true' && '.artifacts/selected-tests.txt' || '' }} - SELECTED_TEST_COUNT: ${{ needs.select-tests.outputs.test-count || '' }} - run: | - python3 .github/workflows/scripts/calculate-backend-test-shards.py - - backend-test: - if: >- - needs.files-changed.outputs.backend == 'true' && - needs.files-changed.outputs.skip_selective_testing != 'true' && - needs.calculate-shards.outputs.shard-count != '0' - needs: [files-changed, select-tests, calculate-shards] - name: backend test (selective) - runs-on: ubuntu-24.04 - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - instance: ${{ fromJSON(needs.calculate-shards.outputs.shard-indices) }} - env: - MATRIX_INSTANCE_TOTAL: ${{ needs.calculate-shards.outputs.shard-count }} - TEST_GROUP_STRATEGY: roundrobin - PYTHONHASHSEED: '0' - XDIST_PER_WORKER_SNUBA: '1' - XDIST_WORKERS: '2' - steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Setup sentry env - uses: ./.github/actions/setup-sentry - id: setup - with: - mode: backend-ci - - - name: Download odiff binary - run: | - curl -sL https://registry.npmjs.org/odiff-bin/-/odiff-bin-4.3.2.tgz \ - | tar -xz --strip-components=2 package/raw_binaries/odiff-linux-x64 - sudo install -m 755 odiff-linux-x64 /usr/local/bin/odiff - rm odiff-linux-x64 - - - name: Bootstrap per-worker Snuba instances - run: | - set -eo pipefail - SNUBA_IMAGE=$(docker inspect snuba-snuba-1 --format '{{.Config.Image}}') - SNUBA_NETWORK=$(docker inspect snuba-snuba-1 --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}') - if [ -z "$SNUBA_IMAGE" ] || [ -z "$SNUBA_NETWORK" ]; then - echo "ERROR: Could not inspect snuba-snuba-1 container. Is devservices running?" - exit 1 - fi - - docker stop snuba-snuba-1 || true - - PIDS=() - for i in $(seq 0 $(( ${XDIST_WORKERS} - 1 ))); do - ( - WORKER_DB="default_gw${i}" - WORKER_PORT=$((1230 + i)) - curl -sf 'http://localhost:8123/' --data-binary "CREATE DATABASE IF NOT EXISTS ${WORKER_DB}" - docker run --rm --network "$SNUBA_NETWORK" \ - -e "CLICKHOUSE_DATABASE=${WORKER_DB}" -e "CLICKHOUSE_HOST=clickhouse" \ - -e "CLICKHOUSE_PORT=9000" -e "CLICKHOUSE_HTTP_PORT=8123" \ - -e "DEFAULT_BROKERS=kafka:9093" -e "REDIS_HOST=redis" \ - -e "REDIS_PORT=6379" -e "REDIS_DB=1" -e "SNUBA_SETTINGS=docker" \ - "$SNUBA_IMAGE" bootstrap --force 2>&1 | tail -3 - docker run -d --name "snuba-gw${i}" --network "$SNUBA_NETWORK" \ - -p "${WORKER_PORT}:1218" \ - -e "CLICKHOUSE_DATABASE=${WORKER_DB}" -e "CLICKHOUSE_HOST=clickhouse" \ - -e "CLICKHOUSE_PORT=9000" -e "CLICKHOUSE_HTTP_PORT=8123" \ - -e "DEFAULT_BROKERS=kafka:9093" -e "REDIS_HOST=redis" \ - -e "REDIS_PORT=6379" -e "REDIS_DB=1" -e "SNUBA_SETTINGS=docker" \ - -e "DEBUG=1" "$SNUBA_IMAGE" api - - for attempt in $(seq 1 30); do - if curl -sf "http://127.0.0.1:${WORKER_PORT}/health" > /dev/null 2>&1; then - echo "snuba-gw${i} healthy on port ${WORKER_PORT}" - break - fi - if [ "$attempt" -eq 30 ]; then - echo "ERROR: snuba-gw${i} failed health check after 30 attempts" - docker logs "snuba-gw${i}" 2>&1 | tail -20 || true - exit 1 - fi - sleep 2 - done - ) & - PIDS+=($!) - done - - for pid in "${PIDS[@]}"; do - wait "$pid" || { echo "ERROR: Snuba bootstrap subshell (PID $pid) failed"; exit 1; } - done - - - name: Download selected tests artifact - if: needs.select-tests.outputs.has-selected-tests == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: selected-tests-${{ github.run_id }} - path: .artifacts/ - - - name: Run backend test (${{ steps.setup.outputs.matrix-instance-number }} of ${{ steps.setup.outputs.matrix-instance-total }}) - env: - SELECTED_TESTS_FILE: ${{ needs.select-tests.outputs.has-selected-tests == 'true' && '.artifacts/selected-tests.txt' || '' }} - run: | - export PYTEST_ADDOPTS="$PYTEST_ADDOPTS -n ${XDIST_WORKERS} --dist=loadfile" - make test-python-ci - - - name: Inspect failure - if: failure() - run: | - if command -v devservices; then - devservices logs - fi - - for i in $(seq 0 $(( ${XDIST_WORKERS} - 1 ))); do - echo "--- snuba-gw${i} logs ---" - docker logs "snuba-gw${i}" 2>&1 | tail -30 || true - done diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index a37fd5da08c65f..79dec6b951e9b5 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -78,13 +78,14 @@ jobs: # Selective testing - only on PRs, determine which tests to run based on coverage data. # This job is skipped on push-to-master where the full suite runs instead. - prepare-selective-tests: + select-tests: if: >- needs.files-changed.outputs.backend == 'true' && needs.files-changed.outputs.skip_selective_testing != 'true' && - github.event_name == 'pull_request' + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository needs: files-changed - name: prepare selective tests + name: select tests runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -95,97 +96,59 @@ jobs: test-count: ${{ steps.compute-tests.outputs.test-count }} steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - fetch-depth: 0 # Need full history for git diff + + - name: Get changed files + id: changed + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files \ + --paginate | python3 .github/workflows/scripts/parse-pr-files.py >> "$GITHUB_OUTPUT" - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: '3.13.1' + python-version-file: '.python-version' - name: Authenticate to Google Cloud - id: gcloud-auth uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.3 with: project_id: sentry-dev-tooling workload_identity_provider: ${{ secrets.SENTRY_GCP_DEV_WORKLOAD_IDENTITY_POOL }} service_account: ${{ secrets.COLLECT_TEST_DATA_SERVICE_ACCOUNT_EMAIL }} - - name: Find coverage data for selective testing - id: find-coverage - env: - GCS_BUCKET: sentry-coverage-data - run: | - set -euo pipefail - - # Get the base commit (what the PR branches from) - BASE_SHA="${{ github.event.pull_request.base.sha }}" - - echo "Looking for coverage data starting from base commit: $BASE_SHA" - - COVERAGE_SHA="" - for sha in $(git rev-list "$BASE_SHA" --max-count=30); do - # Check if coverage exists in GCS for this commit - if gcloud storage ls "gs://${GCS_BUCKET}/${sha}/" &>/dev/null; then - COVERAGE_SHA="$sha" - echo "Found coverage data at commit: $sha" - break - fi - echo "No coverage at $sha, checking parent..." - done - - if [[ -z "$COVERAGE_SHA" ]]; then - echo "No coverage found in last 30 commits, will run full test suite" - echo "found=false" >> "$GITHUB_OUTPUT" - else - echo "found=true" >> "$GITHUB_OUTPUT" - echo "coverage-sha=$COVERAGE_SHA" >> "$GITHUB_OUTPUT" - fi - - name: Download coverage database id: download-coverage - if: steps.find-coverage.outputs.found == 'true' - env: - COVERAGE_SHA: ${{ steps.find-coverage.outputs.coverage-sha }} run: | - set -euxo pipefail - mkdir -p .coverage + mkdir -p .artifacts/coverage - if ! gcloud storage cp "gs://sentry-coverage-data/${COVERAGE_SHA}/.coverage.combined" .coverage/; then - echo "Warning: Failed to download coverage file" - echo "coverage-file=" >> "$GITHUB_OUTPUT" - exit 0 - fi + GCS_PATH="gs://getsentry-coverage-data/latest/.coverage.combined" + echo "Fetching coverage DB from: $GCS_PATH" + gcloud storage ls -l "$GCS_PATH" 2>/dev/null || true - if [[ ! -f .coverage/.coverage.combined ]]; then - echo "Warning: Coverage file not found after download" - ls -la .coverage/ || true + if ! gcloud storage cp "$GCS_PATH" \ + .artifacts/coverage/.coverage.combined 2>/dev/null; then + echo "Warning: Failed to download coverage from GCS, will run full test suite" echo "coverage-file=" >> "$GITHUB_OUTPUT" else - echo "Downloaded coverage file: .coverage/.coverage.combined" - echo "coverage-file=.coverage/.coverage.combined" >> "$GITHUB_OUTPUT" + ls -lh .artifacts/coverage/.coverage.combined + echo "coverage-file=.artifacts/coverage/.coverage.combined" >> "$GITHUB_OUTPUT" fi - - name: Get changed files - id: changed-files - run: | - # Get files changed between base and head of PR - BASE_SHA="${{ github.event.pull_request.base.sha }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - - # Use triple-dot syntax to find the merge-base first, so we only get - # changes introduced in this PR, not changes merged to master since branching - CHANGED_FILES=$(git diff --name-only "$BASE_SHA"..."$HEAD_SHA" | tr '\n' ' ') - echo "Changed files: $CHANGED_FILES" - echo "files=$CHANGED_FILES" >> "$GITHUB_OUTPUT" - - name: Compute selected tests id: compute-tests if: steps.download-coverage.outputs.coverage-file != '' env: COVERAGE_DB: ${{ steps.download-coverage.outputs.coverage-file }} - CHANGED_FILES: ${{ steps.changed-files.outputs.files }} - run: make compute-selected-tests + CHANGED_FILES: ${{ steps.changed.outputs.files }} + PREVIOUS_FILENAMES: ${{ steps.changed.outputs.previous-filenames }} + run: | + python3 .github/workflows/scripts/compute-sentry-selected-tests.py \ + --coverage-db "$COVERAGE_DB" \ + --changed-files "$CHANGED_FILES" \ + --previous-filenames "$PREVIOUS_FILENAMES" \ + --output .artifacts/selected-tests.txt \ + --github-output - name: Upload selected tests artifact if: steps.compute-tests.outputs.has-selected-tests == 'true' @@ -196,12 +159,12 @@ jobs: retention-days: 1 calculate-shards: - # Use always() so this job runs even when prepare-selective-tests is skipped (master) + # Use always() so this job runs even when select-tests is skipped (master) if: >- always() && !cancelled() && needs.files-changed.outputs.backend == 'true' - needs: [files-changed, prepare-selective-tests] + needs: [files-changed, select-tests] name: calculate test shards runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -212,17 +175,17 @@ jobs: steps: - name: Use default shards (no selective testing) id: static-shards - if: needs.prepare-selective-tests.outputs.has-selected-tests != 'true' + if: needs.select-tests.outputs.has-selected-tests != 'true' # Keep in sync with MAX_SHARDS in .github/workflows/scripts/calculate-backend-test-shards.py run: | echo "shard-count=22" >> "$GITHUB_OUTPUT" echo "shard-indices=[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21]" >> "$GITHUB_OUTPUT" - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - if: needs.prepare-selective-tests.outputs.has-selected-tests == 'true' + if: needs.select-tests.outputs.has-selected-tests == 'true' - name: Setup sentry env - if: needs.prepare-selective-tests.outputs.has-selected-tests == 'true' + if: needs.select-tests.outputs.has-selected-tests == 'true' uses: ./.github/actions/setup-sentry id: setup with: @@ -230,7 +193,7 @@ jobs: skip-devservices: true - name: Download selected tests artifact - if: needs.prepare-selective-tests.outputs.has-selected-tests == 'true' + if: needs.select-tests.outputs.has-selected-tests == 'true' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: selected-tests-${{ github.run_id }} @@ -238,20 +201,20 @@ jobs: - name: Calculate test shards id: calculate-shards - if: needs.prepare-selective-tests.outputs.has-selected-tests == 'true' + if: needs.select-tests.outputs.has-selected-tests == 'true' env: SELECTED_TESTS_FILE: '.artifacts/selected-tests.txt' - SELECTED_TEST_COUNT: ${{ needs.prepare-selective-tests.outputs.test-count || '' }} + SELECTED_TEST_COUNT: ${{ needs.select-tests.outputs.test-count || '' }} run: | python3 .github/workflows/scripts/calculate-backend-test-shards.py backend-test: - # Use always() so this job runs even when prepare-selective-tests is skipped (master) + # Use always() so this job runs even when select-tests is skipped (master) if: >- always() && !cancelled() && needs.files-changed.outputs.backend == 'true' && needs.calculate-shards.outputs.shard-count != '0' - needs: [files-changed, prepare-selective-tests, calculate-shards] + needs: [files-changed, select-tests, calculate-shards] name: backend test runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -346,7 +309,7 @@ jobs: done - name: Download selected tests artifact - if: needs.prepare-selective-tests.outputs.has-selected-tests == 'true' + if: needs.select-tests.outputs.has-selected-tests == 'true' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: selected-tests-${{ github.run_id }} @@ -354,7 +317,7 @@ jobs: - name: Run backend test (${{ steps.setup.outputs.matrix-instance-number }} of ${{ steps.setup.outputs.matrix-instance-total }}) env: - SELECTED_TESTS_FILE: ${{ needs.prepare-selective-tests.outputs.has-selected-tests == 'true' && '.artifacts/selected-tests.txt' || '' }} + SELECTED_TESTS_FILE: ${{ needs.select-tests.outputs.has-selected-tests == 'true' && '.artifacts/selected-tests.txt' || '' }} run: | if [ -n "${XDIST_WORKERS}" ]; then export PYTEST_ADDOPTS="$PYTEST_ADDOPTS -n ${XDIST_WORKERS} --dist=loadfile" diff --git a/.github/workflows/getsentry-dispatch-selective.yml b/.github/workflows/getsentry-dispatch-selective.yml deleted file mode 100644 index 577c7879c57665..00000000000000 --- a/.github/workflows/getsentry-dispatch-selective.yml +++ /dev/null @@ -1,100 +0,0 @@ -# Parallel dispatch that passes changed files for selective testing. -# Runs alongside getsentry-dispatch.yml during rollout to validate that -# selective testing produces correct results without affecting the existing -# dispatch. Remove this once selective testing is validated and the changes -# are folded into getsentry-dispatch.yml. -name: getsentry dispatcher (selective testing) - -on: - # XXX: We are using `pull_request_target` instead of `pull_request` because we want - # this to run on forks. It allows forks to access secrets safely by - # only running workflows from the main branch. Prefer to use `pull_request` when possible. - # - # See https://github.com/getsentry/sentry/pull/21600 for more details - pull_request_target: - types: [labeled, opened, reopened, synchronize] - -# disable all other special privileges -permissions: - # needed for `actions/checkout` to clone the code - contents: read - # needed to remove the pull-request label - pull-requests: write - -jobs: - dispatch: - if: "github.event.action != 'labeled' || github.event.label.name == 'Trigger: getsentry tests'" - name: getsentry dispatch (selective) - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - persist-credentials: false - - - name: permissions - run: | - python3 -uS .github/workflows/scripts/getsentry-dispatch-setup \ - --repo-id ${{ github.event.repository.id }} \ - --pr ${{ github.event.number }} \ - --event ${{ github.event.action }} \ - --username "$ARG_USERNAME" \ - --label-names "$ARG_LABEL_NAMES" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # these can contain special characters - ARG_USERNAME: ${{ github.event.pull_request.user.login }} - ARG_LABEL_NAMES: ${{ toJSON(github.event.pull_request.labels.*.name) }} - - - name: Check for file changes - uses: dorny/paths-filter@0bc4621a3135347011ad047f9ecf449bf72ce2bd # v3.0.0 - id: changes - with: - token: ${{ github.token }} - filters: .github/file-filters.yml - - - name: Get changed files for selective testing - id: changed-files - env: - GH_TOKEN: ${{ github.token }} - run: | - CHANGED_FILES=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.number }}/files --paginate --jq '.[].filename' | tr '\n' ' ') - echo "Changed files: $CHANGED_FILES" - echo "sentry-changed-files=$CHANGED_FILES" >> "$GITHUB_OUTPUT" - - - name: getsentry token - uses: getsentry/action-github-app-token@d4b5da6c5e37703f8c3b3e43abb5705b46e159cc # v3.0.0 - id: getsentry - with: - app_id: ${{ vars.SENTRY_INTERNAL_APP_ID }} - private_key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }} - - - name: Wait for PR merge commit - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - id: mergecommit - with: - github-token: ${{ steps.getsentry.outputs.token }} - script: | - const { waitForMergeCommit } = await import(`${process.env.GITHUB_WORKSPACE}/.github/workflows/scripts/wait-for-merge-commit.js`); - await waitForMergeCommit({ - github, - context, - core, - }); - - - name: Dispatch getsentry tests - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - env: - SENTRY_CHANGED_FILES: ${{ steps.changed-files.outputs.sentry-changed-files }} - with: - github-token: ${{ steps.getsentry.outputs.token }} - script: | - const { dispatch } = await import(`${process.env.GITHUB_WORKSPACE}/.github/workflows/scripts/getsentry-dispatch.js`); - await dispatch({ - github, - context, - core, - mergeCommitSha: '${{ steps.mergecommit.outputs.mergeCommitSha }}', - fileChanges: ${{ toJson(steps.changes.outputs) }}, - sentryChangedFiles: process.env.SENTRY_CHANGED_FILES, - targetWorkflow: 'backend-selective.yml', - }); diff --git a/.github/workflows/getsentry-dispatch.yml b/.github/workflows/getsentry-dispatch.yml index d2a75fe6d10442..8c5d6166286510 100644 --- a/.github/workflows/getsentry-dispatch.yml +++ b/.github/workflows/getsentry-dispatch.yml @@ -48,6 +48,14 @@ jobs: token: ${{ github.token }} filters: .github/file-filters.yml + - name: Get changed files for selective testing + id: changed-files + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api repos/${{ github.repository }}/pulls/${{ github.event.number }}/files \ + --paginate | python3 .github/workflows/scripts/parse-pr-files.py >> "$GITHUB_OUTPUT" + - name: getsentry token uses: getsentry/action-github-app-token@5c1e90706fe007857338ac1bfbd7a4177db2f789 # v4.0.0 id: getsentry @@ -70,6 +78,9 @@ jobs: - name: Dispatch getsentry tests uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + SENTRY_CHANGED_FILES: ${{ steps.changed-files.outputs.files }} + SENTRY_PREVIOUS_FILENAMES: ${{ steps.changed-files.outputs.previous-filenames }} with: github-token: ${{ steps.getsentry.outputs.token }} script: | @@ -80,4 +91,6 @@ jobs: core, mergeCommitSha: '${{ steps.mergecommit.outputs.mergeCommitSha }}', fileChanges: ${{ toJson(steps.changes.outputs) }}, + sentryChangedFiles: process.env.SENTRY_CHANGED_FILES, + sentryPreviousFilenames: process.env.SENTRY_PREVIOUS_FILENAMES, }); diff --git a/.github/workflows/scripts/fixtures/pr-files-with-rename.json b/.github/workflows/scripts/fixtures/pr-files-with-rename.json new file mode 100644 index 00000000000000..0c2284e4d95594 --- /dev/null +++ b/.github/workflows/scripts/fixtures/pr-files-with-rename.json @@ -0,0 +1,47 @@ +[ + { + "sha": "798034c0653b888a6d07869c47c7662e55a48ec1", + "filename": ".agents/skills/hybrid-cloud-outboxes/SKILL.md", + "status": "modified", + "additions": 1, + "deletions": 1, + "changes": 2, + "blob_url": "https://github.com/getsentry/sentry/blob/96b8ad19/.agents%2Fskills%2Fhybrid-cloud-outboxes%2FSKILL.md", + "raw_url": "https://github.com/getsentry/sentry/raw/96b8ad19/.agents%2Fskills%2Fhybrid-cloud-outboxes%2FSKILL.md", + "contents_url": "https://api.github.com/repos/getsentry/sentry/contents/.agents%2Fskills%2Fhybrid-cloud-outboxes%2FSKILL.md?ref=96b8ad19" + }, + { + "sha": "91c952f9e0174cc8f574b7f7e9b07353102c5682", + "filename": ".agents/skills/hybrid-cloud-outboxes/references/signal-receivers.md", + "status": "modified", + "additions": 1, + "deletions": 1, + "changes": 2, + "blob_url": "https://github.com/getsentry/sentry/blob/96b8ad19/.agents%2Fskills%2Fhybrid-cloud-outboxes%2Freferences%2Fsignal-receivers.md", + "raw_url": "https://github.com/getsentry/sentry/raw/96b8ad19/.agents%2Fskills%2Fhybrid-cloud-outboxes%2Freferences%2Fsignal-receivers.md", + "contents_url": "https://api.github.com/repos/getsentry/sentry/contents/.agents%2Fskills%2Fhybrid-cloud-outboxes%2Freferences%2Fsignal-receivers.md?ref=96b8ad19" + }, + { + "sha": "9b306a8ce766ae0cae8975c9da9954b305e62498", + "filename": ".agents/skills/hybrid-cloud-rpc/SKILL.md", + "status": "modified", + "additions": 5, + "deletions": 5, + "changes": 10, + "blob_url": "https://github.com/getsentry/sentry/blob/96b8ad19/.agents%2Fskills%2Fhybrid-cloud-rpc%2FSKILL.md", + "raw_url": "https://github.com/getsentry/sentry/raw/96b8ad19/.agents%2Fskills%2Fhybrid-cloud-rpc%2FSKILL.md", + "contents_url": "https://api.github.com/repos/getsentry/sentry/contents/.agents%2Fskills%2Fhybrid-cloud-rpc%2FSKILL.md?ref=96b8ad19" + }, + { + "sha": "442d665073e560105377fc137d7fc4a4868cb57e", + "filename": "tests/sentry/hybridcloud/test_cell.py", + "status": "renamed", + "additions": 28, + "deletions": 30, + "changes": 58, + "previous_filename": "tests/sentry/hybridcloud/test_region.py", + "blob_url": "https://github.com/getsentry/sentry/blob/96b8ad19/tests%2Fsentry%2Fhybridcloud%2Ftest_cell.py", + "raw_url": "https://github.com/getsentry/sentry/raw/96b8ad19/tests%2Fsentry%2Fhybridcloud%2Ftest_cell.py", + "contents_url": "https://api.github.com/repos/getsentry/sentry/contents/tests%2Fsentry%2Fhybridcloud%2Ftest_cell.py?ref=96b8ad19" + } +] diff --git a/.github/workflows/scripts/parse-pr-files.py b/.github/workflows/scripts/parse-pr-files.py new file mode 100644 index 00000000000000..a027c0c0312eb9 --- /dev/null +++ b/.github/workflows/scripts/parse-pr-files.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Parse the GitHub PR files API response into changed files and previous filenames. + +Reads JSON from stdin (the output of `gh api .../pulls/N/files --paginate`). +Note: --paginate can emit multiple JSON arrays (one per page), so we handle +concatenated arrays by decoding incrementally. + +Outputs two lines (suitable for appending to $GITHUB_OUTPUT): + files= + previous-filenames= + +Usage: + gh api repos/OWNER/REPO/pulls/N/files --paginate \ + | python3 parse-pr-files.py >> "$GITHUB_OUTPUT" +""" + +from __future__ import annotations + +import json +import sys + + +def main() -> None: + raw = sys.stdin.read() + decoder = json.JSONDecoder() + files = [] + idx = 0 + while idx < len(raw): + while idx < len(raw) and raw[idx].isspace(): + idx += 1 + if idx >= len(raw): + break + obj, end = decoder.raw_decode(raw, idx) + if isinstance(obj, list): + files.extend(obj) + idx = end + + changed = [f["filename"] for f in files] + previous = [ + f["previous_filename"] + for f in files + if f.get("status") == "renamed" and f.get("previous_filename") + ] + + print(f"files={' '.join(changed)}") + print(f"previous-filenames={' '.join(previous)}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/scripts/selective-testing/compute-selected-tests.py b/.github/workflows/scripts/selective-testing/compute-selected-tests.py deleted file mode 100644 index fb452fd10c5bde..00000000000000 --- a/.github/workflows/scripts/selective-testing/compute-selected-tests.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import os -import re -import sqlite3 -import sys -from pathlib import Path - -# Files/patterns that, if matched by any changed file, should trigger the full test suite. -# Strings are matched as suffixes, re.Pattern entries are matched with .search(). -FULL_SUITE_TRIGGERS: list[str | re.Pattern[str]] = [ - "sentry/testutils/pytest/sentry.py", - "pyproject.toml", - "Makefile", - "sentry/conf/server.py", - "sentry/web/urls.py", - # Django migrations can affect schema and invalidate selective test coverage - re.compile(r"/migrations/\d{4}_[^/]+\.py$"), -] - -# These test files are excluded if they aren't explicitly modified. -EXCLUDED_TEST_FILES: set[str] = { - # this is selected very frequently since it covers the majority of - # app warmup, and is almost never actually relevant to changed files - "tests/sentry/test_wsgi.py", -} - -# Non-source files that don't appear in coverage data but have known test -# dependencies. When one of these files is changed, the mapped test files -# are added to the selected set so selective testing covers them without -# falling back to a full suite run. -EXTRA_FILE_TO_TEST_MAPPING: dict[str, list[str]] = { - ".github/CODEOWNERS": ["tests/sentry/api/test_api_owners.py"], -} - -# Tests that should always be run even if not explicitly selected. -ALWAYS_RUN_TESTS: set[str] = { - "tests/sentry/taskworker/test_config.py", -} - - -def _matches_trigger(file_path: str, trigger: str | re.Pattern[str]) -> bool: - if isinstance(trigger, re.Pattern): - return trigger.search(file_path) is not None - return file_path.endswith(trigger) - - -def should_run_full_suite(changed_files: list[str]) -> bool: - for file_path in changed_files: - if any(_matches_trigger(file_path, t) for t in FULL_SUITE_TRIGGERS): - return True - return False - - -# Test directories excluded from backend test runs (must match calculate-backend-test-shards.py) -EXCLUDED_TEST_PATTERNS: list[str | re.Pattern[str]] = [ - re.compile(r"^tests/(acceptance|apidocs|js|tools)/"), -] - - -def get_changed_test_files(changed_files: list[str]) -> set[str]: - test_files: set[str] = set() - for file_path in changed_files: - if file_path.startswith("tests/") and file_path.endswith(".py"): - if not any(_matches_trigger(file_path, p) for p in EXCLUDED_TEST_PATTERNS): - test_files.add(file_path) - return test_files - - -def get_affected_test_files(coverage_db_path: str, changed_files: list[str]) -> set[str]: - affected_test_files: set[str] = set() - - conn = sqlite3.connect(coverage_db_path) - cur = conn.cursor() - - # Verify required tables exist (need context tracking enabled) - tables = { - r[0] for r in cur.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() - } - if "line_bits" not in tables or "context" not in tables: - raise ValueError( - "Coverage database missing line_bits/context tables. " - "Coverage must be collected with --cov-context=test" - ) - - test_contexts: set[str] = set() - - for file_path in changed_files: - cur.execute( - """ - SELECT c.context, lb.numbits - FROM line_bits lb - JOIN file f ON lb.file_id = f.id - JOIN context c ON lb.context_id = c.id - WHERE f.path LIKE '%' || ? - AND c.context != '' - """, - (f"%{file_path}",), - ) - - for context, bitblob in cur.fetchall(): - if any(b != 0 for b in bytes(bitblob)): - test_contexts.add(context) - - conn.close() - - # Extract test file paths from contexts - # Context format: 'tests/foo/bar.py::TestClass::test_function|run' - for context in test_contexts: - test_file = context.split("::", 1)[0] - affected_test_files.add(test_file) - - return affected_test_files - - -def main() -> int: - parser = argparse.ArgumentParser(description="Compute selected tests from coverage data") - parser.add_argument("--coverage-db", required=True, help="Path to coverage SQLite database") - parser.add_argument( - "--changed-files", required=True, help="Space-separated list of changed files" - ) - parser.add_argument("--output", help="Output file path for selected test files (one per line)") - parser.add_argument("--github-output", action="store_true", help="Write to GITHUB_OUTPUT") - args = parser.parse_args() - - coverage_db = Path(args.coverage_db) - if not coverage_db.exists(): - print(f"Error: Coverage database not found: {coverage_db}", file=sys.stderr) - return 1 - - changed_files = [f.strip() for f in args.changed_files.split() if f.strip()] - if not changed_files: - print("No changed files provided, running full test suite") - affected_test_files: set[str] = set() - elif should_run_full_suite(changed_files): - triggered_by = [ - f for f in changed_files if any(_matches_trigger(f, t) for t in FULL_SUITE_TRIGGERS) - ] - print(f"Full test suite triggered by: {', '.join(triggered_by)}") - affected_test_files = set() - else: - print(f"Computing selected tests for {len(changed_files)} changed files...") - try: - affected_test_files = get_affected_test_files(str(coverage_db), changed_files) - except sqlite3.Error as e: - print(f"Error querying coverage database: {e}", file=sys.stderr) - return 1 - - affected_test_files -= EXCLUDED_TEST_FILES - - # Include tests for non-source files with known test dependencies - for file_path in changed_files: - mapped_tests = EXTRA_FILE_TO_TEST_MAPPING.get(file_path, []) - if mapped_tests: - print(f"Including {len(mapped_tests)} mapped test files for {file_path}") - affected_test_files.update(mapped_tests) - - # Also include test files that were directly modified or added in the PR. - # Note: we intentionally exclude deleted test files here — they can't be - # run, and their coverage is already captured by the lookup above (any - # OTHER test that covered the now-deleted source will be included via - # get_affected_test_files). Deleted test files that appear in the - # coverage results are removed by the filter below. - changed_test_files = get_changed_test_files(changed_files) - existing_changed_test_files = {f for f in changed_test_files if Path(f).exists()} - if existing_changed_test_files: - print(f"Including {len(existing_changed_test_files)} directly changed test files") - affected_test_files.update(existing_changed_test_files) - - # Include tests that should always be run - affected_test_files.update(ALWAYS_RUN_TESTS) - - # Filter out any test files found via coverage lookup that no longer exist - # (e.g. a deleted test file that covered the same source as another changed file). - existing_files = {f for f in affected_test_files if Path(f).exists()} - deleted_files = affected_test_files - existing_files - if deleted_files: - print( - f"Excluding {len(deleted_files)} deleted test file(s) found via coverage: " - + ", ".join(sorted(deleted_files)) - ) - affected_test_files = existing_files - - print(f"Found {len(affected_test_files)} affected test files") - - if args.output: - output_path = Path(args.output) - output_path.parent.mkdir(parents=True, exist_ok=True) - with output_path.open("w") as f: - for test_file in sorted(affected_test_files): - f.write(f"{test_file}\n") - print(f"Wrote selected tests to {output_path}") - - if args.github_output: - github_output = os.environ.get("GITHUB_OUTPUT") - if github_output: - with open(github_output, "a") as f: - f.write(f"test-count={len(affected_test_files)}\n") - f.write(f"has-selected-tests={'true' if affected_test_files else 'false'}\n") - print(f"Wrote to GITHUB_OUTPUT: test-count={len(affected_test_files)}") - - if affected_test_files: - print("\nAffected test files:") - for test_file in sorted(affected_test_files): - print(f" {test_file}") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/workflows/scripts/selective-testing/confirm-test-selection.py b/.github/workflows/scripts/selective-testing/confirm-test-selection.py deleted file mode 100644 index 23bb9ce5019f82..00000000000000 --- a/.github/workflows/scripts/selective-testing/confirm-test-selection.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import sys -from pathlib import Path - -LARGE_SELECTION_THRESHOLD = 300 - - -def main() -> int: - if len(sys.argv) < 2: - print("Usage: confirm-test-selection.py ", file=sys.stderr) - return 1 - - selected_tests_path = Path(sys.argv[1]) - - if not selected_tests_path.exists(): - print(f"Selected tests file not found: {selected_tests_path}", file=sys.stderr) - return 1 - - selected_files = [ - line.strip() for line in selected_tests_path.read_text().splitlines() if line.strip() - ] - count = len(selected_files) - - if count == 0: - prompt = ( - "The full test suite will be run, usually due to a change in a file that triggers the full suite (see logs above).\n" - "Continue? [y/N] " - ) - elif count >= LARGE_SELECTION_THRESHOLD: - prompt = f"{count} test files selected, a large amount to run locally. Continue? [y/N] " - else: - print(f"{count} test files selected") - return 0 - - try: - response = input(prompt).strip().lower() - except (EOFError, KeyboardInterrupt): - print("\nAborted.") - return 1 - - if response in ("y", "yes"): - return 0 - - print("Aborted.") - return 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/workflows/scripts/selective-testing/fetch-coverage.py b/.github/workflows/scripts/selective-testing/fetch-coverage.py deleted file mode 100644 index f3ecb1e60bedaa..00000000000000 --- a/.github/workflows/scripts/selective-testing/fetch-coverage.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import shutil -import subprocess -import sys -import urllib.request -from pathlib import Path - -GCS_BUCKET = "sentry-coverage-data" -GCS_BASE_URL = f"https://storage.googleapis.com/{GCS_BUCKET}" -COVERAGE_FILENAME = ".coverage.combined" -DEFAULT_MAX_COMMITS = 30 - - -def detect_base_ref() -> str: - result = subprocess.run( - ["git", "merge-base", "origin/master", "HEAD"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - return result.stdout.strip() - - print("Error: Could not find merge-base with origin/master", file=sys.stderr) - print("Make sure you have fetched from the remote (git fetch origin)", file=sys.stderr) - sys.exit(1) - - -def get_commit_list(base_ref: str) -> list[str]: - result = subprocess.run( - ["git", "rev-list", base_ref, f"--max-count={DEFAULT_MAX_COMMITS}"], - capture_output=True, - text=True, - check=True, - ) - return [sha.strip() for sha in result.stdout.strip().splitlines() if sha.strip()] - - -def check_coverage_exists(sha: str) -> bool: - url = f"{GCS_BASE_URL}/{sha}/{COVERAGE_FILENAME}" - req = urllib.request.Request(url, method="HEAD") - try: - urllib.request.urlopen(req, timeout=5) - return True - except Exception as e: - print(f" Warning: Error checking {sha[:12]}: {e}", file=sys.stderr) - return False - - -def download_coverage(sha: str, output_path: Path) -> bool: - cache_dir = Path.home() / ".cache" / "sentry" / "coverage" - cache_dir.mkdir(parents=True, exist_ok=True) - cached_file = cache_dir / sha / COVERAGE_FILENAME - - if cached_file.exists(): - print(f"Using cached coverage data for {sha[:12]}") - output_path.parent.mkdir(parents=True, exist_ok=True) - # Copy from cache (symlink would break if cache is cleaned) - shutil.copy2(cached_file, output_path) - return True - - url = f"{GCS_BASE_URL}/{sha}/{COVERAGE_FILENAME}" - print(f"Downloading coverage data from {sha[:12]}...") - - try: - urllib.request.urlretrieve(url, str(output_path)) - except (urllib.error.HTTPError, urllib.error.URLError) as e: - print(f"Error downloading coverage data: {e}", file=sys.stderr) - return False - - # Cache the download - cached_file.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(output_path, cached_file) - print(f"Cached coverage data at {cached_file}") - - return True - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Fetch coverage data from GCS for selective testing" - ) - parser.add_argument( - "--base-ref", - help="Base git ref to walk history from (default: origin/master)", - ) - parser.add_argument( - "--output", - default=".cache/coverage.db", - help="Output path for the coverage database (default: .cache/coverage.db)", - ) - args = parser.parse_args() - - base_ref = args.base_ref or detect_base_ref() - output_path = Path(args.output) - - print(f"Looking for coverage data from {base_ref} (up to {DEFAULT_MAX_COMMITS} commits)") - - commits = get_commit_list(base_ref) - if not commits: - print("No commits found to check", file=sys.stderr) - return 1 - - for sha in commits: - print(f" Checking {sha[:12]}...", end=" ") - if check_coverage_exists(sha): - print("found!") - output_path.parent.mkdir(parents=True, exist_ok=True) - if download_coverage(sha, output_path): - print(f"Coverage database written to {output_path}") - return 0 - else: - return 1 - else: - print("no coverage") - - print(f"No coverage data found in last {DEFAULT_MAX_COMMITS} commits", file=sys.stderr) - return 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/workflows/scripts/test_parse_pr_files.py b/.github/workflows/scripts/test_parse_pr_files.py new file mode 100644 index 00000000000000..fdee382d68ec4a --- /dev/null +++ b/.github/workflows/scripts/test_parse_pr_files.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Tests for parse-pr-files.py.""" + +from __future__ import annotations + +import importlib.util +import io +import sys +from pathlib import Path +from unittest import mock + +_script_path = Path(__file__).parent / "parse-pr-files.py" +_spec = importlib.util.spec_from_file_location("parse_pr_files", _script_path) +_mod = importlib.util.module_from_spec(_spec) +sys.modules["parse_pr_files"] = _mod +_spec.loader.exec_module(_mod) + +from parse_pr_files import main + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +def _run(json_input: str) -> dict[str, str]: + """Run main() with json_input on stdin, return output as key-value dict.""" + buf = io.StringIO() + with mock.patch("sys.stdin", io.StringIO(json_input)): + with mock.patch("sys.stdout", buf): + main() + return dict(line.split("=", 1) for line in buf.getvalue().strip().split("\n")) + + +class TestParsePrFiles: + def test_real_response_with_rename(self): + """Vendored fixture from getsentry/sentry#111009 (3 modified + 1 renamed).""" + fixture = (FIXTURES_DIR / "pr-files-with-rename.json").read_text() + out = _run(fixture) + + files = out["files"].split() + assert len(files) == 4 + assert "tests/sentry/hybridcloud/test_cell.py" in files + assert ".agents/skills/hybrid-cloud-rpc/SKILL.md" in files + + assert out["previous-filenames"] == "tests/sentry/hybridcloud/test_region.py" + + def test_no_renames(self): + out = _run('[{"filename": "src/foo.py", "status": "modified"}]') + assert out["files"] == "src/foo.py" + assert out["previous-filenames"] == "" + + def test_multiple_renames(self): + out = _run( + """[ + {"filename": "b.py", "status": "renamed", "previous_filename": "a.py"}, + {"filename": "d.py", "status": "renamed", "previous_filename": "c.py"}, + {"filename": "e.py", "status": "added"} + ]""" + ) + assert out["files"] == "b.py d.py e.py" + assert out["previous-filenames"] == "a.py c.py" + + def test_empty_list(self): + out = _run("[]") + assert out["files"] == "" + assert out["previous-filenames"] == "" + + def test_paginated_response(self): + """gh api --paginate emits concatenated JSON arrays, one per page.""" + page1 = '[{"filename": "a.py", "status": "modified"}]' + page2 = '[{"filename": "b.py", "status": "renamed", "previous_filename": "old_b.py"}]' + out = _run(page1 + page2) + assert out["files"] == "a.py b.py" + assert out["previous-filenames"] == "old_b.py" + + def test_paginated_response_with_whitespace(self): + """Pages may be separated by newlines.""" + page1 = '[{"filename": "a.py", "status": "modified"}]' + page2 = '[{"filename": "b.py", "status": "added"}]' + out = _run(page1 + "\n" + page2) + assert out["files"] == "a.py b.py" + assert out["previous-filenames"] == "" diff --git a/Makefile b/Makefile index 2a53e999fab8e9..3a3554bfc81023 100644 --- a/Makefile +++ b/Makefile @@ -153,36 +153,6 @@ test-backend-ci-with-coverage: -o junit_suite_name=pytest @echo "" -compute-selected-tests: - @echo "--> Computing selected tests from coverage data" - python3 .github/workflows/scripts/selective-testing/compute-selected-tests.py \ - --coverage-db "$(COVERAGE_DB)" \ - --changed-files "$(CHANGED_FILES)" \ - --output .artifacts/selected-tests.txt \ - --github-output - @echo "" - -test-selective: - @echo "--> Running selective tests based on branch changes" - python3 .github/workflows/scripts/selective-testing/fetch-coverage.py \ - --output .cache/coverage.db - python3 .github/workflows/scripts/selective-testing/compute-selected-tests.py \ - --coverage-db .cache/coverage.db \ - --changed-files "$$(git diff --name-only $$(git merge-base origin/master HEAD))" \ - --output .cache/selected-tests.txt - python3 .github/workflows/scripts/selective-testing/confirm-test-selection.py \ - .cache/selected-tests.txt - SELECTED_TESTS_FILE=.cache/selected-tests.txt \ - python3 -b -m pytest \ - tests \ - --reuse-db \ - --ignore tests/acceptance \ - --ignore tests/apidocs \ - --ignore tests/js \ - --ignore tests/tools \ - -svv - @echo "" - # it's not possible to change settings.DATABASE after django startup, so # unfortunately these tests must be run in a separate pytest process. References: # * https://docs.djangoproject.com/en/4.2/topics/testing/tools/#overriding-settings diff --git a/package.json b/package.json index 368d4d7d9ef294..2584865f8c2d39 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@emotion/is-prop-valid": "^1.3.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@intercom/messenger-js-sdk": "^0.0.18", "@mdx-js/loader": "^3.1.0", "@popperjs/core": "^2.11.5", "@r4ai/remark-callout": "^0.6.2", @@ -170,7 +171,7 @@ "echarts-for-react": "3.0.6", "esbuild": "0.25.10", "focus-trap": "7.6.5", - "framer-motion": "12.23.12", + "framer-motion": "12.38.0", "fuse.js": "^6.6.2", "gettext-parser": "7.0.1", "gl-matrix": "3.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 926d36c93de1c0..21ac43b1c1672b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@emotion/styled': specifier: ^11.14.0 version: 11.14.0(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.3))(@types/react@19.2.1)(react@19.2.3) + '@intercom/messenger-js-sdk': + specifier: ^0.0.18 + version: 0.0.18 '@mdx-js/loader': specifier: ^3.1.0 version: 3.1.0(acorn@8.16.0)(webpack@5.99.6(esbuild@0.25.10)) @@ -373,8 +376,8 @@ importers: specifier: 7.6.5 version: 7.6.5 framer-motion: - specifier: 12.23.12 - version: 12.23.12(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 12.38.0 + version: 12.38.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) fuse.js: specifier: ^6.6.2 version: 6.6.2 @@ -1970,6 +1973,9 @@ packages: resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==} engines: {node: '>=18.18'} + '@intercom/messenger-js-sdk@0.0.18': + resolution: {integrity: sha512-OQbhnNh26cdI0ddIVh67JOGnSTFAHrbKF5atXuOeWpDF2Ups3O7Do1Oz42BrvQA/o0AZF+1Wqaxtc3kq70wc6w==} + '@internationalized/date@3.11.0': resolution: {integrity: sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==} @@ -6140,8 +6146,8 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - framer-motion@12.23.12: - resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==} + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -7600,11 +7606,11 @@ packages: moo-color@1.0.3: resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==} - motion-dom@12.23.12: - resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} - motion-utils@12.23.6: - resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} @@ -11309,6 +11315,8 @@ snapshots: '@humanwhocodes/retry@0.4.2': {} + '@intercom/messenger-js-sdk@0.0.18': {} + '@internationalized/date@3.11.0': dependencies: '@swc/helpers': 0.5.15 @@ -16349,10 +16357,10 @@ snapshots: forwarded@0.2.0: {} - framer-motion@12.23.12(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + framer-motion@12.38.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - motion-dom: 12.23.12 - motion-utils: 12.23.6 + motion-dom: 12.38.0 + motion-utils: 12.36.0 tslib: 2.8.1 optionalDependencies: '@emotion/is-prop-valid': 1.3.1 @@ -18421,11 +18429,11 @@ snapshots: dependencies: color-name: 1.1.4 - motion-dom@12.23.12: + motion-dom@12.38.0: dependencies: - motion-utils: 12.23.6 + motion-utils: 12.36.0 - motion-utils@12.23.6: {} + motion-utils@12.36.0: {} mri@1.2.0: {} diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index 18c1c1fa8f50af..fbe4b6339b5466 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -920,8 +920,3 @@ def serialize( # type: ignore[override] ) return context - - -# Backwards-compatible aliases for getsentry -DetailedOrganizationSerializer = OrganizationSerializer -DetailedOrganizationSerializerWithProjectsAndTeams = OrganizationWithProjectsAndTeamsSerializer diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 12f13726a2d31d..057cff8966274d 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -882,6 +882,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "sentry.integrations.github.tasks.codecov_account_unlink", "sentry.integrations.github.tasks.link_all_repos", "sentry.integrations.github.tasks.pr_comment", + "sentry.integrations.github.tasks.sync_repos_on_install_change", "sentry.integrations.gitlab.tasks", "sentry.integrations.jira.tasks", "sentry.integrations.opsgenie.tasks", diff --git a/src/sentry/deletions/defaults/repository.py b/src/sentry/deletions/defaults/repository.py index 41d2adedc16009..62ca63fd4a4a29 100644 --- a/src/sentry/deletions/defaults/repository.py +++ b/src/sentry/deletions/defaults/repository.py @@ -10,11 +10,13 @@ def _get_repository_child_relations(instance: Repository) -> list[BaseRelation]: ) from sentry.models.commit import Commit from sentry.models.pullrequest import PullRequest + from sentry.seer.models.project_repository import SeerProjectRepository return [ ModelRelation(Commit, {"repository_id": instance.id}), ModelRelation(PullRequest, {"repository_id": instance.id}), ModelRelation(RepositoryProjectPathConfig, {"repository_id": instance.id}), + ModelRelation(SeerProjectRepository, {"repository_id": instance.id}), ] diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 00f103ab2aebce..58d6483c89ec42 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -137,9 +137,12 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:integrations-cursor", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-copilot-agent", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-platform-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + manager.add("organizations:github-repo-auto-sync", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) manager.add("organizations:integrations-perforce", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-slack-staging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:scm-source-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # API-driven integration setup pipeline (per-provider rollout) + manager.add("organizations:integration-api-pipeline-github", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Project Management Integrations Feature Parity Flags manager.add("organizations:integrations-github_enterprise-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-gitlab-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) @@ -447,9 +450,15 @@ def register_temporary_features(manager: FeatureManager) -> None: # Use workflow engine exclusively for OrganizationCombinedRuleIndexEndpoint.get results. # See src/sentry/workflow_engine/docs/legacy_backport.md for context. manager.add("organizations:workflow-engine-combinedruleindex-get", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Use workflow engine exclusively for OrganizationIncidentDetailsEndpoint.get results. + # See src/sentry/workflow_engine/docs/legacy_backport.md for context. + manager.add("organizations:workflow-engine-orgincidentdetails-get", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Use workflow engine exclusively for legacy issue alert rule.get results. # See src/sentry/workflow_engine/docs/legacy_backport.md for context. manager.add("organizations:workflow-engine-issue-alert-endpoints-get", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Use workflow engine exclusively for OrganizationAlertRuleDetailsEndpoint.get results. + # See src/sentry/workflow_engine/docs/legacy_backport.md for context. + manager.add("organizations:workflow-engine-orgalertruledetails-get", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable metric detector limits by plan type manager.add("organizations:workflow-engine-metric-detector-limit", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable EventUniqueUserFrequencyConditionWithConditions special alert condition diff --git a/src/sentry/grouping/parameterization.py b/src/sentry/grouping/parameterization.py index 85043c06239069..a1be4c4a3376a7 100644 --- a/src/sentry/grouping/parameterization.py +++ b/src/sentry/grouping/parameterization.py @@ -18,8 +18,6 @@ class ParameterizationRegex: name: str # name of the pattern (also used as group name in combined regex) raw_pattern: str # regex pattern w/o matching group name raw_pattern_experimental: str | None = None - lookbehind: str | None = None # positive lookbehind prefix if needed - lookahead: str | None = None # positive lookahead postfix if needed # Function which takes the matched value and returns the replacement value. replacement_callback: ParameterizationReplacementFunction | None = None @@ -38,11 +36,9 @@ def experimental_pattern(self) -> str | None: def _get_pattern(self, raw_pattern: str) -> str: """ - Returns the regex pattern with a named matching group and lookbehind/lookahead if needed. + Returns the regex pattern inside of a named matching group. """ - prefix = rf"(?<={self.lookbehind})" if self.lookbehind else "" - postfix = rf"(?={self.lookahead})" if self.lookahead else "" - return rf"{prefix}(?P<{self.name}>{raw_pattern}){postfix}" + return rf"(?P<{self.name}>{raw_pattern})" def is_valid_ip(maybe_ip_str: str) -> bool: @@ -311,23 +307,25 @@ def is_valid_ip(maybe_ip_str: str) -> bool: ParameterizationRegex( name="quoted_str", raw_pattern=r""" - '([^']+)' | "([^"]+)" + # Lookbehind to ensure we'll only match the value half of `=`-type key-value + # pairs, rather than all quoted strings + (?<=[=]) + ( + '([^']+)' | + "([^"]+)" + ) """, - # Using an `=` lookbehind guarantees we'll only match the value half of key-value pairs, - # rather than all quoted strings - lookbehind="=", ), ParameterizationRegex( name="bool", raw_pattern=r""" - True | - true | - False | - false + # Lookbehind to ensure we'll only match the value half of `=`-type key-value + # pairs, rather than all instances of the words 'true' and 'false' + (?<=[=]) + ( + True | true | False | false + ) """, - # Using an `=` lookbehind guarantees we'll only match the value half of key-value pairs, - # rather than all instances of the words 'true' and 'false'. - lookbehind="=", ), ] @@ -401,6 +399,9 @@ def parameterize(self, input_str: str) -> str: replacement_counts: defaultdict[str, int] = defaultdict(int) # Track whether any regex matches don't lead to a replacement found_false_positive = False + # Flag allowing us to only count false positives during the main parameterization, not the + # fallback run + emit_false_positive_metric = True def _handle_regex_match(match: re.Match[str]) -> str: # Ensure we're dealing with the flag from the outer scope, rather than shadowing it @@ -435,6 +436,16 @@ def _handle_regex_match(match: re.Match[str]) -> str: else: found_false_positive = True + # This is only true during the main combo-regex parameterization, not during + # fallback, so that we don't double-count these occurrences + if emit_false_positive_metric: + # Track the number of false positive matches, and what pattern produced them. We + # can compare this to the same key's `grouping.value_parameterized` metric below + # to see how often our maybe-matches pan out to be actual matches. + metrics.incr( + "grouping.parameterization_false_positive", tags={"key": matched_key} + ) + return replacement_string with metrics.timer( @@ -454,6 +465,9 @@ def _handle_regex_match(match: re.Match[str]) -> str: replacement_counts = defaultdict(int) parameterized = input_str + # Prevent double-counting of false positives + emit_false_positive_metric = False + # Apply patterns one by one, with no short-circuiting for regex_key, regex in self.compiled_regexes_by_name.items(): parameterized = regex.sub(_handle_regex_match, parameterized) diff --git a/src/sentry/incidents/endpoints/bases.py b/src/sentry/incidents/endpoints/bases.py index c05d556977499c..6e57d9d01047e6 100644 --- a/src/sentry/incidents/endpoints/bases.py +++ b/src/sentry/incidents/endpoints/bases.py @@ -154,6 +154,10 @@ def convert_args( class WorkflowEngineOrganizationAlertRuleEndpoint(OrganizationAlertRuleEndpoint): + # Subclasses may set a per-method granular flag (e.g. for GET) that is OR'd + # with the broad workflow-engine-rule-serializers flag. + workflow_engine_method_flags: dict[str, str] = {} + def convert_args( self, request: Request, alert_rule_id: int, *args: Any, **kwargs: Any ) -> tuple[tuple[Any, ...], dict[str, Any]]: @@ -169,7 +173,10 @@ def convert_args( ): raise ResourceDoesNotExist - if features.has("organizations:workflow-engine-rule-serializers", organization): + method_flag = self.workflow_engine_method_flags.get(request.method or "") + if features.has("organizations:workflow-engine-rule-serializers", organization) or ( + method_flag is not None and features.has(method_flag, organization) + ): try: ard = AlertRuleDetector.objects.get( alert_rule_id=validated_alert_rule_id, diff --git a/src/sentry/incidents/endpoints/organization_alert_rule_details.py b/src/sentry/incidents/endpoints/organization_alert_rule_details.py index 970ed75f5017bc..764fd14e030e0c 100644 --- a/src/sentry/incidents/endpoints/organization_alert_rule_details.py +++ b/src/sentry/incidents/endpoints/organization_alert_rule_details.py @@ -333,6 +333,9 @@ def wrapper( @extend_schema(tags=["Alerts"]) @cell_silo_endpoint class OrganizationAlertRuleDetailsEndpoint(WorkflowEngineOrganizationAlertRuleEndpoint): + workflow_engine_method_flags = { + "GET": "organizations:workflow-engine-orgalertruledetails-get", + } owner = ApiOwner.ISSUES publish_status = { "DELETE": ApiPublishStatus.PUBLIC, diff --git a/src/sentry/incidents/endpoints/organization_incident_details.py b/src/sentry/incidents/endpoints/organization_incident_details.py index ead172d0fefdf7..3ea693980caa46 100644 --- a/src/sentry/incidents/endpoints/organization_incident_details.py +++ b/src/sentry/incidents/endpoints/organization_incident_details.py @@ -72,9 +72,11 @@ def convert_args( if not features.has("organizations:incidents", organization, actor=request.user): raise ResourceDoesNotExist - if request.method == "GET" and features.has( - "organizations:workflow-engine-rule-serializers", organization - ): + has_workflow_engine_flags = features.has( + "organizations:workflow-engine-orgincidentdetails-get", organization + ) or features.has("organizations:workflow-engine-rule-serializers", organization) + + if request.method == "GET" and has_workflow_engine_flags: gop: GroupOpenPeriod | None = None # Try the association table first (dual-written data). diff --git a/src/sentry/integrations/bitbucket/repository.py b/src/sentry/integrations/bitbucket/repository.py index 78aa2a539075db..17610a849ad399 100644 --- a/src/sentry/integrations/bitbucket/repository.py +++ b/src/sentry/integrations/bitbucket/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.integrations.types import IntegrationProviderSlug @@ -47,7 +48,7 @@ def get_webhook_secret(self, organization): return secret def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/bitbucket_server/repository.py b/src/sentry/integrations/bitbucket_server/repository.py index 6b3bab8c6c463d..528e2bd6bd9466 100644 --- a/src/sentry/integrations/bitbucket_server/repository.py +++ b/src/sentry/integrations/bitbucket_server/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from datetime import datetime, timezone from typing import Any @@ -35,7 +36,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/github/repository.py b/src/sentry/integrations/github/repository.py index b901fc89a839fd..766c1e03a8a4d7 100644 --- a/src/sentry/integrations/github/repository.py +++ b/src/sentry/integrations/github/repository.py @@ -52,7 +52,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: return { "name": data["identifier"], diff --git a/src/sentry/integrations/github/tasks/__init__.py b/src/sentry/integrations/github/tasks/__init__.py index a635eebb4b9af1..cc31059167a9fb 100644 --- a/src/sentry/integrations/github/tasks/__init__.py +++ b/src/sentry/integrations/github/tasks/__init__.py @@ -2,10 +2,12 @@ from .codecov_account_unlink import codecov_account_unlink from .link_all_repos import link_all_repos from .pr_comment import github_comment_workflow +from .sync_repos_on_install_change import sync_repos_on_install_change __all__ = ( "codecov_account_link", "codecov_account_unlink", "github_comment_workflow", "link_all_repos", + "sync_repos_on_install_change", ) diff --git a/src/sentry/integrations/github/tasks/link_all_repos.py b/src/sentry/integrations/github/tasks/link_all_repos.py index ade3e8ef83a7e0..046c0fe466236f 100644 --- a/src/sentry/integrations/github/tasks/link_all_repos.py +++ b/src/sentry/integrations/github/tasks/link_all_repos.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Mapping from typing import Any from taskbroker_client.retry import Retry @@ -13,6 +14,7 @@ from sentry.organizations.services.organization import organization_service from sentry.plugins.providers.integration_repository import ( RepoExistsError, + RepositoryInputConfig, get_integration_repository_provider, ) from sentry.shared_integrations.exceptions import ApiError @@ -23,9 +25,9 @@ logger = logging.getLogger(__name__) -def get_repo_config(repo, integration_id): +def get_repo_config(repo: Mapping[str, Any], integration_id: int) -> RepositoryInputConfig: return { - "external_id": repo["id"], + "external_id": str(repo["id"]), "integration_id": integration_id, "identifier": repo["full_name"], } @@ -77,7 +79,7 @@ def link_all_repos( integration_repo_provider = get_integration_repository_provider(integration) - repo_configs: list[dict[str, Any]] = [] + repo_configs: list[RepositoryInputConfig] = [] missing_repos = [] for repo in repositories: try: diff --git a/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py b/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py new file mode 100644 index 00000000000000..c3ab3b70155163 --- /dev/null +++ b/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py @@ -0,0 +1,136 @@ +import logging +from typing import Literal + +from taskbroker_client.retry import Retry + +from sentry import features +from sentry.constants import ObjectStatus +from sentry.integrations.github.webhook_types import GitHubInstallationRepo +from sentry.integrations.services.integration import integration_service +from sentry.integrations.services.integration.model import RpcIntegration +from sentry.integrations.services.repository.service import repository_service +from sentry.integrations.source_code_management.metrics import ( + SCMIntegrationInteractionEvent, + SCMIntegrationInteractionType, +) +from sentry.organizations.services.organization import organization_service +from sentry.organizations.services.organization.model import RpcOrganization +from sentry.plugins.providers.integration_repository import ( + RepoExistsError, + RepositoryInputConfig, + get_integration_repository_provider, +) +from sentry.silo.base import SiloMode +from sentry.tasks.base import instrumented_task, retry +from sentry.taskworker.namespaces import integrations_control_tasks + +from .link_all_repos import get_repo_config + +logger = logging.getLogger(__name__) + + +@instrumented_task( + name="sentry.integrations.github.tasks.sync_repos_on_install_change", + namespace=integrations_control_tasks, + retry=Retry(times=3, delay=120), + processing_deadline_duration=120, + silo_mode=SiloMode.CONTROL, +) +@retry(exclude=(RepoExistsError, KeyError)) +def sync_repos_on_install_change( + integration_id: int, + action: str, + repos_added: list[GitHubInstallationRepo], + repos_removed: list[GitHubInstallationRepo], + repository_selection: Literal["all", "selected"], +) -> None: + """ + Handle GitHub installation_repositories webhook events. + + Creates Repository records for newly accessible repos and disables + records for repos that are no longer accessible, across all orgs + linked to the integration. + """ + result = integration_service.organization_contexts(integration_id=integration_id) + integration = result.integration + org_integrations = result.organization_integrations + + if integration is None or integration.status != ObjectStatus.ACTIVE: + logger.info( + "sync_repos_on_install_change.missing_or_inactive_integration", + extra={"integration_id": integration_id}, + ) + return + + if not org_integrations: + logger.info( + "sync_repos_on_install_change.no_org_integrations", + extra={"integration_id": integration_id}, + ) + return + + provider = f"integrations:{integration.provider}" + + for oi in org_integrations: + organization_id = oi.organization_id + rpc_org = organization_service.get(id=organization_id) + + if rpc_org is None: + logger.info( + "sync_repos_on_install_change.missing_organization", + extra={"organization_id": organization_id}, + ) + continue + + if not features.has("organizations:github-repo-auto-sync", rpc_org): + continue + + with SCMIntegrationInteractionEvent( + interaction_type=SCMIntegrationInteractionType.SYNC_REPOS_ON_INSTALL_CHANGE, + integration_id=integration_id, + organization_id=organization_id, + provider_key=integration.provider, + ).capture(): + _sync_repos_for_org( + integration=integration, + rpc_org=rpc_org, + provider=provider, + repos_added=repos_added, + repos_removed=repos_removed, + ) + + +def _sync_repos_for_org( + *, + integration: RpcIntegration, + rpc_org: RpcOrganization, + provider: str, + repos_added: list[GitHubInstallationRepo], + repos_removed: list[GitHubInstallationRepo], +) -> None: + if repos_added: + integration_repo_provider = get_integration_repository_provider(integration) + repo_configs: list[RepositoryInputConfig] = [] + for repo in repos_added: + try: + repo_configs.append(get_repo_config(repo, integration.id)) + except KeyError: + logger.exception("Failed to translate repository config") + continue + + if repo_configs: + try: + integration_repo_provider.create_repositories( + configs=repo_configs, organization=rpc_org + ) + except RepoExistsError: + pass + + if repos_removed: + external_ids = [str(repo["id"]) for repo in repos_removed] + repository_service.disable_repositories_by_external_ids( + organization_id=rpc_org.id, + integration_id=integration.id, + provider=provider, + external_ids=external_ids, + ) diff --git a/src/sentry/integrations/github/webhook.py b/src/sentry/integrations/github/webhook.py index b5b86fea0c0a87..28e87abd1ef559 100644 --- a/src/sentry/integrations/github/webhook.py +++ b/src/sentry/integrations/github/webhook.py @@ -30,6 +30,7 @@ from sentry.integrations.github.webhook_types import ( GITHUB_WEBHOOK_TYPE_HEADER_KEY, GithubWebhookType, + InstallationRepositoriesEvent, ) from sentry.integrations.pipeline import ensure_integration from sentry.integrations.services.integration.model import ( @@ -418,6 +419,57 @@ def _handle_organization_deletion( ) +class InstallationRepositoriesEventWebhook(GitHubWebhook): + """ + Handles installation_repositories events when repos are added to or + removed from the GitHub App installation. Runs in control silo. + + https://docs.github.com/en/webhooks/webhook-events-and-payloads#installation_repositories + """ + + EVENT_TYPE = IntegrationWebhookEventType.INSTALLATION_REPOSITORIES + + def __call__( # type: ignore[override] + self, event: InstallationRepositoriesEvent, host: str | None = None, **kwargs: Any + ) -> None: + external_id = get_github_external_id(event=event, host=host) + if external_id is None: + return + + result = integration_service.organization_contexts( + provider=self.provider, + external_id=external_id, + ) + integration = result.integration + + if integration is None: + logger.warning( + "github.installation_repositories.missing_integration", + extra={"external_id": str(external_id)}, + ) + return + + action = event["action"] + repos_added = event["repositories_added"] + repos_removed = event["repositories_removed"] + repository_selection = event["repository_selection"] + + if not repos_added and not repos_removed: + return + + from .tasks.sync_repos_on_install_change import sync_repos_on_install_change + + sync_repos_on_install_change.apply_async( + kwargs={ + "integration_id": integration.id, + "action": action, + "repos_added": repos_added, + "repos_removed": repos_removed, + "repository_selection": repository_selection, + } + ) + + class PushEventWebhook(GitHubWebhook): """https://developer.github.com/v3/activity/events/types/#pushevent""" @@ -958,6 +1010,7 @@ class GitHubIntegrationsWebhookEndpoint(Endpoint): _handlers: dict[GithubWebhookType, type[GitHubWebhook]] = { GithubWebhookType.CHECK_RUN: CheckRunEventWebhook, GithubWebhookType.INSTALLATION: InstallationEventWebhook, + GithubWebhookType.INSTALLATION_REPOSITORIES: InstallationRepositoriesEventWebhook, GithubWebhookType.ISSUE: IssuesEventWebhook, GithubWebhookType.ISSUE_COMMENT: IssueCommentEventWebhook, GithubWebhookType.PULL_REQUEST: PullRequestEventWebhook, diff --git a/src/sentry/integrations/github/webhook_types.py b/src/sentry/integrations/github/webhook_types.py index 242b201da7b362..eaad179b8ae10e 100644 --- a/src/sentry/integrations/github/webhook_types.py +++ b/src/sentry/integrations/github/webhook_types.py @@ -1,6 +1,7 @@ from __future__ import annotations from enum import StrEnum +from typing import Any, Literal, TypedDict GITHUB_WEBHOOK_TYPE_HEADER = "HTTP_X_GITHUB_EVENT" GITHUB_WEBHOOK_TYPE_HEADER_KEY = "X-GITHUB-EVENT" @@ -29,3 +30,18 @@ class GithubWebhookType(StrEnum): CELL_PROCESSED_GITHUB_EVENTS = frozenset( t.value for t in GithubWebhookType if t not in _CONTROL_ONLY_EVENTS ) + + +class GitHubInstallationRepo(TypedDict): + id: int + full_name: str + private: bool + + +class InstallationRepositoriesEvent(TypedDict): + action: Literal["added", "removed"] + installation: dict[str, Any] + repositories_added: list[GitHubInstallationRepo] + repositories_removed: list[GitHubInstallationRepo] + repository_selection: Literal["all", "selected"] + sender: dict[str, Any] diff --git a/src/sentry/integrations/github_enterprise/repository.py b/src/sentry/integrations/github_enterprise/repository.py index 5f256206ffd418..2835befdf3918a 100644 --- a/src/sentry/integrations/github_enterprise/repository.py +++ b/src/sentry/integrations/github_enterprise/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.integrations.github.repository import GitHubRepositoryProvider @@ -29,7 +30,7 @@ def _validate_repo(self, client, installation, repo): return repo_data def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: integration = integration_service.get_integration( integration_id=data["integration_id"], provider=self.repo_provider diff --git a/src/sentry/integrations/gitlab/repository.py b/src/sentry/integrations/gitlab/repository.py index 1b889c641c5c71..d2285d73b195f0 100644 --- a/src/sentry/integrations/gitlab/repository.py +++ b/src/sentry/integrations/gitlab/repository.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from sentry.integrations.types import IntegrationProviderSlug @@ -35,7 +36,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: installation = self.get_installation(data.get("installation"), organization.id) client = installation.get_client() diff --git a/src/sentry/integrations/perforce/repository.py b/src/sentry/integrations/perforce/repository.py index 0adea7741301be..52d84dd91c13fa 100644 --- a/src/sentry/integrations/perforce/repository.py +++ b/src/sentry/integrations/perforce/repository.py @@ -102,7 +102,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: """ Build repository configuration for database storage. diff --git a/src/sentry/integrations/services/repository/impl.py b/src/sentry/integrations/services/repository/impl.py index 39238a71778c89..d1cf84cdcb4456 100644 --- a/src/sentry/integrations/services/repository/impl.py +++ b/src/sentry/integrations/services/repository/impl.py @@ -134,6 +134,23 @@ def disable_repositories_for_integration( provider=provider, ).update(status=ObjectStatus.DISABLED) + def disable_repositories_by_external_ids( + self, + *, + organization_id: int, + integration_id: int, + provider: str, + external_ids: list[str], + ) -> None: + with transaction.atomic(router.db_for_write(Repository)): + Repository.objects.filter( + organization_id=organization_id, + integration_id=integration_id, + provider=provider, + external_id__in=external_ids, + status=ObjectStatus.ACTIVE, + ).update(status=ObjectStatus.DISABLED) + def disassociate_organization_integration( self, *, diff --git a/src/sentry/integrations/services/repository/service.py b/src/sentry/integrations/services/repository/service.py index a10d8c42852af3..51cb81c98ba835 100644 --- a/src/sentry/integrations/services/repository/service.py +++ b/src/sentry/integrations/services/repository/service.py @@ -85,6 +85,21 @@ def disable_repositories_for_integration( Code owners and code mappings will not be changed. """ + @cell_rpc_method(resolve=ByOrganizationId()) + @abstractmethod + def disable_repositories_by_external_ids( + self, + *, + organization_id: int, + integration_id: int, + provider: str, + external_ids: list[str], + ) -> None: + """ + Disables specific repositories by external_id for a given integration. + Only active repositories are affected. Code mappings and commits are preserved. + """ + @cell_rpc_method(resolve=ByOrganizationId()) @abstractmethod def disassociate_organization_integration( diff --git a/src/sentry/integrations/source_code_management/metrics.py b/src/sentry/integrations/source_code_management/metrics.py index 6cc035d5bcab32..a6612f5680922b 100644 --- a/src/sentry/integrations/source_code_management/metrics.py +++ b/src/sentry/integrations/source_code_management/metrics.py @@ -41,6 +41,7 @@ class SCMIntegrationInteractionType(StrEnum): # Tasks LINK_ALL_REPOS = "link_all_repos" + SYNC_REPOS_ON_INSTALL_CHANGE = "sync_repos_on_install_change" # GitHub only DERIVE_CODEMAPPINGS = "derive_codemappings" diff --git a/src/sentry/integrations/utils/metrics.py b/src/sentry/integrations/utils/metrics.py index 6d0f8ea33ea22a..a341f8c31833ba 100644 --- a/src/sentry/integrations/utils/metrics.py +++ b/src/sentry/integrations/utils/metrics.py @@ -448,6 +448,7 @@ class IntegrationWebhookEventType(StrEnum): # This represents a webhook event for an inbound sync operation, such as syncing external resources or data into Sentry. INBOUND_SYNC = "inbound_sync" INSTALLATION = "installation" + INSTALLATION_REPOSITORIES = "installation_repositories" ISSUE_COMMENT = "issue_comment" MERGE_REQUEST = "pull_request" MERGE_REQUEST_REVIEW = "pull_request_review" diff --git a/src/sentry/integrations/vsts/repository.py b/src/sentry/integrations/vsts/repository.py index f9a9b74007acfa..ac015771960172 100644 --- a/src/sentry/integrations/vsts/repository.py +++ b/src/sentry/integrations/vsts/repository.py @@ -47,7 +47,7 @@ def get_repository_data( return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: return { "name": data["name"], diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 028c7f36986a52..15b6342ed80098 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -928,6 +928,37 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) register("snuba.search.hits-sample-size", default=100, flags=FLAG_AUTOMATOR_MODIFIABLE) +register( + "snuba.search.recommended.recency-weight", + default=0.20, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) +register( + "snuba.search.recommended.spike-weight", + default=0.20, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) +register( + "snuba.search.recommended.severity-weight", + default=0.20, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) +register( + "snuba.search.recommended.user-impact-weight", + default=0.05, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) +register( + "snuba.search.recommended.event-volume-weight", + default=0.20, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) +register( + "snuba.search.recommended.group-type-boost", + type=Dict, + default={7001: 0.15}, + flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, +) register("snuba.track-outcomes-sample-rate", default=0.0, flags=FLAG_AUTOMATOR_MODIFIABLE) # The percentage of tagkeys that we want to cache. Set to 1.0 in order to cache everything, <=0.0 to stop caching diff --git a/src/sentry/pipeline/types.py b/src/sentry/pipeline/types.py index 947c970f1fea06..01a1c991a2f548 100644 --- a/src/sentry/pipeline/types.py +++ b/src/sentry/pipeline/types.py @@ -41,8 +41,8 @@ class PipelineStepResult: data: dict[str, Any] = field(default_factory=dict) @classmethod - def advance(cls, data: dict[str, Any] | None = None) -> PipelineStepResult: - return cls(action=PipelineStepAction.ADVANCE, data=data or {}) + def advance(cls) -> PipelineStepResult: + return cls(action=PipelineStepAction.ADVANCE) @classmethod def stay(cls, data: dict[str, Any] | None = None) -> PipelineStepResult: diff --git a/src/sentry/plugins/providers/integration_repository.py b/src/sentry/plugins/providers/integration_repository.py index 9be762ce766856..e238eccd3124cc 100644 --- a/src/sentry/plugins/providers/integration_repository.py +++ b/src/sentry/plugins/providers/integration_repository.py @@ -1,8 +1,9 @@ from __future__ import annotations import logging +from collections.abc import Mapping from datetime import timezone -from typing import Any, ClassVar, TypedDict +from typing import Any, ClassVar, NotRequired, TypedDict from dateutil.parser import parse as parse_date from rest_framework import status @@ -27,6 +28,16 @@ from sentry.utils import metrics +class RepositoryInputConfig(TypedDict): + """Input config passed to create_repositories / build_repository_config. + Providers may include additional keys beyond these.""" + + external_id: str + integration_id: int + identifier: str + installation: NotRequired[str] + + class RepositoryConfig(TypedDict): name: str external_id: str @@ -107,7 +118,7 @@ def get_installation( def create_repository( self, - repo_config: dict[str, Any], + repo_config: Mapping[str, Any], organization: RpcOrganization, ): result = self.build_repository_config(organization=organization, data=repo_config) @@ -227,7 +238,7 @@ def _update_repositories( def create_repositories( self, - configs: list[dict[str, Any]], + configs: list[RepositoryInputConfig], organization: RpcOrganization, ): external_id_to_repo_config: dict[str, RepositoryConfig] = {} @@ -354,7 +365,7 @@ def get_repository_data(self, organization, config): return config def build_repository_config( - self, organization: RpcOrganization, data: dict[str, Any] + self, organization: RpcOrganization, data: Mapping[str, Any] ) -> RepositoryConfig: """ Builds final dict containing all necessary data to create the repository diff --git a/src/sentry/replays/usecases/replay_counts.py b/src/sentry/replays/usecases/replay_counts.py index 5c71f306a8bae5..3f8ad6a50cb0c3 100644 --- a/src/sentry/replays/usecases/replay_counts.py +++ b/src/sentry/replays/usecases/replay_counts.py @@ -48,8 +48,8 @@ def get_replay_counts( if snuba_params.start is None or snuba_params.end is None or snuba_params.organization is None: raise ValueError("Must provide start and end") - if isinstance(data_source, Dataset): - data_source = data_source.value + if not isinstance(data_source, Dataset): + data_source = Dataset(data_source) replay_ids_mapping = _get_replay_id_mappings(query, snuba_params, data_source) @@ -75,7 +75,7 @@ def get_replay_counts( def _get_replay_id_mappings( query: str, snuba_params: SnubaParams, - data_source: str = Dataset.Discover.value, + data_source: Dataset = Dataset.Discover, # XXX: the returned list depends on the query and so it could be any type :( ) -> dict[str, list[Any]]: """ @@ -83,9 +83,9 @@ def _get_replay_id_mappings( If select_column is replay_id, return an identity map of replay_id -> [replay_id]. The keys of the returned dict are UUIDs, represented as 32 char hex strings (all '-'s stripped) """ - if data_source == Dataset.Discover.value: + if data_source == Dataset.Discover: search_query_func = discover.query - elif data_source == Dataset.IssuePlatform.value: + elif data_source == Dataset.IssuePlatform: search_query_func = issue_platform.query # type: ignore[assignment] else: raise ValueError("Invalid data source") diff --git a/src/sentry/search/snuba/executors.py b/src/sentry/search/snuba/executors.py index e71c62fdc3cc6a..32ca5b06617737 100644 --- a/src/sentry/search/snuba/executors.py +++ b/src/sentry/search/snuba/executors.py @@ -269,7 +269,7 @@ def _prepare_aggregations( end: datetime, having: Sequence[Sequence[Any]], aggregate_kwargs: TrendsSortWeights | None = None, - replace_trends_aggregation: bool | None = False, + use_issue_platform: bool = False, ) -> list[Any]: extra_aggregations = self.dependency_aggregations.get(sort_field, []) required_aggregations = set([sort_field, "total"] + extra_aggregations) @@ -280,8 +280,8 @@ def _prepare_aggregations( aggregations = [] for alias in required_aggregations: aggregation = self.aggregation_defs[alias] - if replace_trends_aggregation and alias == "trends": - aggregation = self.aggregation_defs["trends_issue_platform"] + if use_issue_platform and alias in ("trends", "recommended"): + aggregation = self.aggregation_defs[f"{alias}_issue_platform"] if callable(aggregation): if aggregate_kwargs: aggregation = aggregation(start, end, aggregate_kwargs.get(alias, {})) @@ -333,14 +333,10 @@ def _prepare_params_for_category( else: conditions.append(converted_filter) - if sort_field == "trends" and group_category is not GroupCategory.ERROR.value: - aggregations = self._prepare_aggregations( - sort_field, start, end, having, aggregate_kwargs, True - ) - else: - aggregations = self._prepare_aggregations( - sort_field, start, end, having, aggregate_kwargs - ) + use_issue_platform = group_category is not GroupCategory.ERROR.value + aggregations = self._prepare_aggregations( + sort_field, start, end, having, aggregate_kwargs, use_issue_platform + ) if cursor is not None: having.append((sort_field, ">=" if cursor.is_prev else "<=", cursor.value)) @@ -700,11 +696,92 @@ def trends_aggregation_impl( ] +def _recommended_aggregation( + timestamp_column: str, type_column: str | None = None +) -> Sequence[str]: + hour = 3600 + + # Recency: exponential decay based on time since last event (24hr halflife) + recency_weight = options.get("snuba.search.recommended.recency-weight") + age_hours = f"divide(minus(now(), max({timestamp_column})), {hour})" + recency = f"divide(1, pow(2, divide({age_hours}, 24)))" + + # Spike: ratio of recent 6hr events to total 3d events + spike_weight = options.get("snuba.search.recommended.spike-weight") + recent_6h = f"countIf(lessOrEquals(minus(now(), {timestamp_column}), {6 * hour}))" + total_3d = f"countIf(lessOrEquals(minus(now(), {timestamp_column}), {3 * 24 * hour}))" + spike = f"least(1.0, divide({recent_6h}, plus({total_3d}, 1)))" + + # Severity: max log level - maps fatal=1.0, error=0.75, warning=0.5, info=0.25, debug=0.0 + severity_weight = options.get("snuba.search.recommended.severity-weight") + severity = ( + "max(multiIf(" + "equals(level, 'fatal'), 1.0, " + "equals(level, 'error'), 0.75, " + "equals(level, 'warning'), 0.5, " + "equals(level, 'info'), 0.25, " + "0.0))" + ) + + # User impact: ln(uniq(tags[sentry:user]) + 1)/ln(1001) - maps 1→~0, 10→0.33, 100→0.67, 1000→1.0 + user_impact_weight = options.get("snuba.search.recommended.user-impact-weight") + user_impact = "least(1.0, divide(log(plus(uniq(tags[sentry:user]), 1)), log(1001)))" + + # Event volume: ln(count() + 1)/ln(10001) - maps 1→~0, 10→0.25, 100→0.50, 1000→0.75, 10000+→1.0 + event_volume_weight = options.get("snuba.search.recommended.event-volume-weight") + event_volume = "least(1.0, divide(log(plus(count(), 1)), log(10001)))" + + # Group type boost: additive signal per issue type + group_type_boosts = options.get("snuba.search.recommended.group-type-boost") + if group_type_boosts: + type_expr = f"any({type_column})" if type_column else "1" + conditions = [] + for type_id, boost in group_type_boosts.items(): + conditions.append(f"equals({type_expr}, {type_id}), {boost}") + type_boost = f"multiIf({', '.join(conditions)}, 0.0)" + else: + type_boost = "0.0" + + return [ + ( + f"plus(plus(plus(plus(plus(" + f"multiply({recency_weight}, {recency}), " + f"multiply({spike_weight}, {spike})), " + f"multiply({severity_weight}, {severity})), " + f"multiply({user_impact_weight}, {user_impact})), " + f"multiply({event_volume_weight}, {event_volume})), " + f"{type_boost})" + ), + "", + ] + + +def recommended_aggregation( + start: datetime, + end: datetime, + aggregate_kwargs: Any = None, +) -> Sequence[str]: + return _recommended_aggregation(timestamp_column="timestamp") + + +def recommended_issue_platform_aggregation( + start: datetime, + end: datetime, + aggregate_kwargs: Any = None, +) -> Sequence[str]: + return _recommended_aggregation( + timestamp_column="client_timestamp", type_column="occurrence_type_id" + ) + + class PostgresSnubaQueryExecutor(AbstractQueryExecutor): ISSUE_FIELD_NAME = "group_id" logger = logging.getLogger("sentry.search.postgressnuba") - dependency_aggregations = {"trends": ["last_seen", "times_seen"]} + dependency_aggregations = { + "trends": ["last_seen", "times_seen"], + "recommended": ["last_seen", "times_seen", "user_count"], + } postgres_only_fields = {*SKIP_SNUBA_FIELDS, "regressed_in_release"} # add specific fields here on top of skip_snuba_fields from the serializer sort_strategies = { @@ -712,6 +789,7 @@ class PostgresSnubaQueryExecutor(AbstractQueryExecutor): "freq": "times_seen", "new": "first_seen", "trends": "trends", + "recommended": "recommended", "user": "user_count", # We don't need a corresponding snuba field here, since this sort only happens # in Postgres @@ -723,10 +801,12 @@ class PostgresSnubaQueryExecutor(AbstractQueryExecutor): "first_seen": ["multiply(toUInt64(min(coalesce(group_first_seen, timestamp))), 1000)", ""], "last_seen": ["multiply(toUInt64(max(timestamp)), 1000)", ""], "trends": trends_aggregation, + "recommended": recommended_aggregation, # Only makes sense with WITH TOTALS, returns 1 for an individual group. "total": ["uniq", ISSUE_FIELD_NAME], "user_count": ["uniq", "tags[sentry:user]"], "trends_issue_platform": trends_issue_platform_aggregation, + "recommended_issue_platform": recommended_issue_platform_aggregation, } @property diff --git a/src/sentry/seer/explorer/tools.py b/src/sentry/seer/explorer/tools.py index 7764c7cb40b1c3..c5d5dc1df390df 100644 --- a/src/sentry/seer/explorer/tools.py +++ b/src/sentry/seer/explorer/tools.py @@ -371,7 +371,9 @@ def execute_trace_table_query( raise -def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: +def get_trace_waterfall( + trace_id: str, organization_id: int, additional_attributes: list[str] | None = None +) -> EAPTrace | None: """ Get the full span waterfall and connected errors for a trace. @@ -382,6 +384,8 @@ def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: Returns: The spans and errors in the trace, along with the full 32-character trace ID. """ + if additional_attributes is None: + additional_attributes = ["span.status_code"] try: organization = Organization.objects.get(id=organization_id) @@ -416,7 +420,7 @@ def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: events = query_trace_data( snuba_params, full_trace_id, - additional_attributes=["span.status_code"], + additional_attributes=additional_attributes, referrer=Referrer.SEER_EXPLORER_TOOLS, organization=organization, ) @@ -428,8 +432,10 @@ def get_trace_waterfall(trace_id: str, organization_id: int) -> EAPTrace | None: ) -def rpc_get_trace_waterfall(trace_id: str, organization_id: int) -> dict[str, Any]: - trace = get_trace_waterfall(trace_id, organization_id) +def rpc_get_trace_waterfall( + trace_id: str, organization_id: int, additional_attributes: list[str] | None = None +) -> dict[str, Any]: + trace = get_trace_waterfall(trace_id, organization_id, additional_attributes) return trace.dict() if trace else {} diff --git a/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py b/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py index 98f5f8d087f6a4..c3cadbddde864c 100644 --- a/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py +++ b/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py @@ -12,7 +12,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission -from sentry.models.group import Group +from sentry.models.group import STATUS_QUERY_CHOICES, Group from sentry.models.organization import Organization from sentry.seer.signed_seer_api import ( SeerViewerContext, @@ -55,12 +55,21 @@ def get(self, request: Request, organization: Organization) -> Response: status=status_codes.HTTP_400_BAD_REQUEST, ) - valid_group_ids = set( - Group.objects.filter( - id__in=group_ids, - project__organization=organization, - ).values_list("id", flat=True) + group_qs = Group.objects.filter( + id__in=group_ids, + project__organization=organization, ) + + status_param = request.GET.get("status") + if status_param is not None: + if status_param not in STATUS_QUERY_CHOICES: + return Response( + {"detail": "Invalid status parameter"}, + status=status_codes.HTTP_400_BAD_REQUEST, + ) + group_qs = group_qs.filter(status=STATUS_QUERY_CHOICES[status_param]) + + valid_group_ids = set(group_qs.values_list("id", flat=True)) group_ids = [gid for gid in group_ids if gid in valid_group_ids] if not group_ids: diff --git a/static/app/bootstrap/commonInitialization.tsx b/static/app/bootstrap/commonInitialization.tsx index 0ae520feeafd00..761bd66fcef362 100644 --- a/static/app/bootstrap/commonInitialization.tsx +++ b/static/app/bootstrap/commonInitialization.tsx @@ -1,7 +1,13 @@ -import {NODE_ENV, UI_DEV_ENABLE_PROFILING} from 'sentry/constants'; +import {MotionGlobalConfig} from 'framer-motion'; + +import {IS_ACCEPTANCE_TEST, NODE_ENV, UI_DEV_ENABLE_PROFILING} from 'sentry/constants'; import {ConfigStore} from 'sentry/stores/configStore'; import type {Config} from 'sentry/types/system'; +if (IS_ACCEPTANCE_TEST || NODE_ENV === 'test') { + MotionGlobalConfig.skipAnimations = true; +} + export function commonInitialization(config: Config) { if (NODE_ENV === 'development') { import(/* webpackMode: "eager" */ 'sentry/utils/silence-react-unsafe-warnings'); diff --git a/static/app/components/checkInTimeline/timelineCursor.tsx b/static/app/components/checkInTimeline/timelineCursor.tsx index 41f67e303bf9f0..8035d4fad285ea 100644 --- a/static/app/components/checkInTimeline/timelineCursor.tsx +++ b/static/app/components/checkInTimeline/timelineCursor.tsx @@ -4,7 +4,6 @@ import {AnimatePresence, motion} from 'framer-motion'; import {Overlay} from 'sentry/components/overlay'; import {Sticky} from 'sentry/components/sticky'; -import {testableTransition} from 'sentry/utils/testableTransition'; const TOOLTIP_OFFSET = 10; @@ -142,7 +141,7 @@ function useTimelineCursor({ initial="initial" animate="animate" exit="exit" - transition={testableTransition({duration: 0.1})} + transition={{duration: 0.1}} variants={{ initial: {opacity: 0}, animate: {opacity: 1}, diff --git a/static/app/components/checkInTimeline/timelineZoom.tsx b/static/app/components/checkInTimeline/timelineZoom.tsx index 3d704d8d3ed1ad..be5bb0192361e1 100644 --- a/static/app/components/checkInTimeline/timelineZoom.tsx +++ b/static/app/components/checkInTimeline/timelineZoom.tsx @@ -2,8 +2,6 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import styled from '@emotion/styled'; import {AnimatePresence, motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - /** * The minimum number in pixels which the selection should be considered valid * and will fire the onSelect handler. @@ -157,7 +155,7 @@ function useTimelineZoom({enabled = true, onSelect}: Opti initial="initial" animate="animate" exit="exit" - transition={testableTransition({duration: 0.2})} + transition={{duration: 0.2}} variants={{ initial: {opacity: 0}, animate: {opacity: 1}, diff --git a/static/app/components/core/loader/indeterminateLoader.tsx b/static/app/components/core/loader/indeterminateLoader.tsx index bf723e44a29098..232594ffce01d9 100644 --- a/static/app/components/core/loader/indeterminateLoader.tsx +++ b/static/app/components/core/loader/indeterminateLoader.tsx @@ -7,8 +7,6 @@ import {AnimatePresence, motion} from 'framer-motion'; import {Stack} from '@sentry/scraps/layout'; -import {testableTransition} from 'sentry/utils/testableTransition'; - // required to break import cycle // eslint-disable-next-line no-relative-import-paths/no-relative-import-paths import {Text} from '../text/text'; @@ -131,7 +129,7 @@ export function IndeterminateLoader({ initial={{opacity: 0}} animate={{opacity: 1}} exit={{opacity: 0}} - transition={testableTransition({duration: 0.3})} + transition={{duration: 0.3}} > {currentMessage} diff --git a/static/app/components/core/toast/toast.tsx b/static/app/components/core/toast/toast.tsx index 501556ce74d843..9cc83bf22f8fe4 100644 --- a/static/app/components/core/toast/toast.tsx +++ b/static/app/components/core/toast/toast.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; import classNames from 'classnames'; -import {motion, type HTMLMotionProps} from 'framer-motion'; +import {motion, type HTMLMotionProps, type Transition} from 'framer-motion'; import {Button} from '@sentry/scraps/button'; import {Container, Flex} from '@sentry/scraps/layout'; @@ -11,7 +11,6 @@ import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {TextOverflow} from 'sentry/components/textOverflow'; import {IconCheckmark, IconRefresh, IconWarning} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {testableTransition} from 'sentry/utils/testableTransition'; import type {Theme} from 'sentry/utils/theme'; interface ToastProps { @@ -55,11 +54,11 @@ const TOAST_TRANSITION = { initial: {opacity: 0, y: 70}, animate: {opacity: 1, y: 0}, exit: {opacity: 0, y: 70}, - transition: testableTransition({ + transition: { type: 'spring', stiffness: 450, damping: 25, - }), + } satisfies Transition, }; function ToastIcon({type}: {type: Indicator['type']}) { diff --git a/static/app/components/events/autofix/autofixChanges.tsx b/static/app/components/events/autofix/autofixChanges.tsx index b2a3eb10ef2a17..98f2ae50ae1e90 100644 --- a/static/app/components/events/autofix/autofixChanges.tsx +++ b/static/app/components/events/autofix/autofixChanges.tsx @@ -33,7 +33,6 @@ import {t} from 'sentry/locale'; import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {MarkedText} from 'sentry/utils/marked/markedText'; import {useMutation, useQueryClient} from 'sentry/utils/queryClient'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useApi} from 'sentry/utils/useApi'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -109,7 +108,7 @@ const cardAnimationProps: MotionNodeAnimationOptions = { exit: {opacity: 0, height: 0, scale: 0.8, y: -20}, initial: {opacity: 0, height: 0, scale: 0.8}, animate: {opacity: 1, height: 'auto', scale: 1}, - transition: testableTransition({ + transition: { duration: 1.0, height: { type: 'spring', @@ -123,7 +122,7 @@ const cardAnimationProps: MotionNodeAnimationOptions = { type: 'tween', ease: 'easeOut', }, - }), + }, }; function BranchButton({change}: {change: AutofixCodebaseChange}) { diff --git a/static/app/components/events/autofix/autofixHighlightPopup.tsx b/static/app/components/events/autofix/autofixHighlightPopup.tsx index 8772059515c0d0..7f7944b19dcd9f 100644 --- a/static/app/components/events/autofix/autofixHighlightPopup.tsx +++ b/static/app/components/events/autofix/autofixHighlightPopup.tsx @@ -29,7 +29,6 @@ import {IconClose, IconSeer} from 'sentry/icons'; import {t} from 'sentry/locale'; import {trackAnalytics} from 'sentry/utils/analytics'; import {MarkedText} from 'sentry/utils/marked/markedText'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useApi} from 'sentry/utils/useApi'; import {useMedia} from 'sentry/utils/useMedia'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -646,9 +645,9 @@ export function AutofixHighlightPopup(props: Props) { initial={{opacity: 0, x: 10}} animate={{opacity: 1, x: 0}} exit={{opacity: 0, x: 10}} - transition={testableTransition({ + transition={{ duration: 0.2, - })} + }} style={{ left: `${position.left}px`, top: `${position.top}px`, diff --git a/static/app/components/events/autofix/autofixOutputStream.tsx b/static/app/components/events/autofix/autofixOutputStream.tsx index d3e78ac1c66b15..1c562d33df4a5d 100644 --- a/static/app/components/events/autofix/autofixOutputStream.tsx +++ b/static/app/components/events/autofix/autofixOutputStream.tsx @@ -20,7 +20,6 @@ import {IconRefresh, IconSeer} from 'sentry/icons'; import {t} from 'sentry/locale'; import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {useMutation, useQueryClient} from 'sentry/utils/queryClient'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useApi} from 'sentry/utils/useApi'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -269,25 +268,25 @@ export function AutofixOutputStream({ initial={{opacity: 0, height: 0}} animate={{opacity: 1, height: 'auto'}} exit={{opacity: 0, height: 0}} - transition={testableTransition({ + transition={{ duration: 0.2, height: { type: 'spring', bounce: 0.2, }, - })} + }} > diff --git a/static/app/components/events/autofix/autofixRootCause.tsx b/static/app/components/events/autofix/autofixRootCause.tsx index 71cb8d3966a626..af45a1cdc747a3 100644 --- a/static/app/components/events/autofix/autofixRootCause.tsx +++ b/static/app/components/events/autofix/autofixRootCause.tsx @@ -33,7 +33,6 @@ import type {Event} from 'sentry/types/event'; import {trackAnalytics} from 'sentry/utils/analytics'; import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {useMutation, useQuery, useQueryClient} from 'sentry/utils/queryClient'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useApi} from 'sentry/utils/useApi'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; @@ -99,7 +98,7 @@ const cardAnimationProps: MotionNodeAnimationOptions = { exit: {opacity: 0, height: 0, scale: 0.8, y: -20}, initial: {opacity: 0, height: 0, scale: 0.8}, animate: {opacity: 1, height: 'auto', scale: 1}, - transition: testableTransition({ + transition: { duration: 1.0, height: { type: 'spring', @@ -113,7 +112,7 @@ const cardAnimationProps: MotionNodeAnimationOptions = { type: 'tween', ease: 'easeOut', }, - }), + }, }; export function replaceHeadersWithBold(markdown: string) { diff --git a/static/app/components/events/autofix/autofixSolution.tsx b/static/app/components/events/autofix/autofixSolution.tsx index 17a8eb57dfa37d..73d7e5595216c0 100644 --- a/static/app/components/events/autofix/autofixSolution.tsx +++ b/static/app/components/events/autofix/autofixSolution.tsx @@ -34,7 +34,6 @@ import {trackAnalytics} from 'sentry/utils/analytics'; import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {valueIsEqual} from 'sentry/utils/object/valueIsEqual'; import {setApiQueryData, useMutation, useQueryClient} from 'sentry/utils/queryClient'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useApi} from 'sentry/utils/useApi'; import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -134,7 +133,7 @@ const cardAnimationProps: MotionNodeAnimationOptions = { exit: {opacity: 0, height: 0, scale: 0.8, y: -20}, initial: {opacity: 0, height: 0, scale: 0.8}, animate: {opacity: 1, height: 'auto', scale: 1}, - transition: testableTransition({ + transition: { duration: 1.0, height: { type: 'spring', @@ -148,7 +147,7 @@ const cardAnimationProps: MotionNodeAnimationOptions = { type: 'tween', ease: 'easeOut', }, - }), + }, }; function SolutionDescription({ diff --git a/static/app/components/events/autofix/autofixSteps.tsx b/static/app/components/events/autofix/autofixSteps.tsx index cb3a8eb9d95242..6cbf91873a8d7f 100644 --- a/static/app/components/events/autofix/autofixSteps.tsx +++ b/static/app/components/events/autofix/autofixSteps.tsx @@ -22,14 +22,13 @@ import {useAutofixRepos} from 'sentry/components/events/autofix/useAutofix'; import {getAutofixRunErrorMessage} from 'sentry/components/events/autofix/utils'; import {t} from 'sentry/locale'; import type {Event} from 'sentry/types/event'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useOrganization} from 'sentry/utils/useOrganization'; const animationProps: MotionNodeAnimationOptions = { exit: {opacity: 0}, initial: {opacity: 0}, animate: {opacity: 1}, - transition: testableTransition({duration: 0.3}), + transition: {duration: 0.3}, }; interface StepProps { groupId: string; diff --git a/static/app/components/events/autofix/codingAgentCard.tsx b/static/app/components/events/autofix/codingAgentCard.tsx index 0d8c61bea560e2..78ab07d3ae3620 100644 --- a/static/app/components/events/autofix/codingAgentCard.tsx +++ b/static/app/components/events/autofix/codingAgentCard.tsx @@ -21,13 +21,12 @@ import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {IconCode, IconOpen} from 'sentry/icons'; import {t} from 'sentry/locale'; import {sanitizedMarkedNoHeadings} from 'sentry/utils/marked/marked'; -import {testableTransition} from 'sentry/utils/testableTransition'; const animationProps: MotionNodeAnimationOptions = { exit: {opacity: 0}, initial: {opacity: 0}, animate: {opacity: 1}, - transition: testableTransition({duration: 0.3}), + transition: {duration: 0.3}, }; interface CodingAgentCardProps { diff --git a/static/app/components/events/autofix/insights/autofixInsightCard.tsx b/static/app/components/events/autofix/insights/autofixInsightCard.tsx index 5e4f842be5cd0d..6a3a7c7a5ecd20 100644 --- a/static/app/components/events/autofix/insights/autofixInsightCard.tsx +++ b/static/app/components/events/autofix/insights/autofixInsightCard.tsx @@ -17,7 +17,6 @@ import {t} from 'sentry/locale'; import {singleLineRenderer} from 'sentry/utils/marked/marked'; import {MarkedText} from 'sentry/utils/marked/markedText'; import {ellipsize} from 'sentry/utils/string/ellipsize'; -import {testableTransition} from 'sentry/utils/testableTransition'; interface AutofixInsightCardProps { groupId: string; @@ -34,7 +33,7 @@ export const cardAnimationProps = { exit: {opacity: 0, height: 0, scale: 0.8, y: -20}, initial: {opacity: 0, height: 0, scale: 0.8}, animate: {opacity: 1, height: 'auto', scale: 1}, - transition: testableTransition({ + transition: { duration: 1.0, height: { type: 'spring', @@ -48,7 +47,7 @@ export const cardAnimationProps = { type: 'tween', ease: 'easeOut', }, - }), + }, }; export function FlippedReturnIcon(props: React.HTMLAttributes) { diff --git a/static/app/components/events/autofix/v2/utils.tsx b/static/app/components/events/autofix/v2/utils.tsx index 889ead33cc02ce..8e0010fb8edd41 100644 --- a/static/app/components/events/autofix/v2/utils.tsx +++ b/static/app/components/events/autofix/v2/utils.tsx @@ -4,7 +4,6 @@ import {type MotionNodeAnimationOptions} from 'framer-motion'; import {inlineCodeStyles} from '@sentry/scraps/code'; import {MarkedText} from 'sentry/utils/marked/markedText'; -import {testableTransition} from 'sentry/utils/testableTransition'; /** * Animation props for artifact cards and status cards. @@ -13,7 +12,7 @@ export const cardAnimationProps: MotionNodeAnimationOptions = { exit: {opacity: 0, height: 0, scale: 0.8, y: -20}, initial: {opacity: 0, height: 0, scale: 0.8}, animate: {opacity: 1, height: 'auto', scale: 1}, - transition: testableTransition({ + transition: { duration: 0.12, height: { type: 'spring', @@ -27,7 +26,7 @@ export const cardAnimationProps: MotionNodeAnimationOptions = { type: 'tween', ease: 'easeOut', }, - }), + }, }; /** diff --git a/static/app/components/events/interfaces/crashContent/exception/stackTrace.tsx b/static/app/components/events/interfaces/crashContent/exception/stackTrace.tsx index 1047813b513b07..e6acafb7fe1a3e 100644 --- a/static/app/components/events/interfaces/crashContent/exception/stackTrace.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/stackTrace.tsx @@ -11,7 +11,6 @@ import type {PlatformKey} from 'sentry/types/project'; import {StackType, StackView} from 'sentry/types/stacktrace'; import {defined} from 'sentry/utils'; import {isNativePlatform} from 'sentry/utils/platform'; -import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; type Props = { chainedException: boolean; @@ -44,7 +43,6 @@ export function StackTrace({ frameSourceMapDebuggerData, stackType, }: Props) { - const hasStreamlinedUI = useHasStreamlinedUI(); if (!defined(stacktrace)) { return null; } @@ -90,7 +88,6 @@ export function StackTrace({ newestFirst={newestFirst} event={event} meta={meta} - hideIcon={hasStreamlinedUI} /> ); } @@ -107,7 +104,6 @@ export function StackTrace({ threadId={threadId} frameSourceMapDebuggerData={frameSourceMapDebuggerData} hideSourceMapDebugger={stackType === StackType.MINIFIED} - hideIcon={hasStreamlinedUI} /> ); } diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/content.spec.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/content.spec.tsx index 65a467940673f3..92c1bd51c072a4 100644 --- a/static/app/components/events/interfaces/crashContent/stackTrace/content.spec.tsx +++ b/static/app/components/events/interfaces/crashContent/stackTrace/content.spec.tsx @@ -1,6 +1,5 @@ import {EventFixture} from 'sentry-fixture/event'; import {EventEntryStacktraceFixture} from 'sentry-fixture/eventEntryStacktrace'; -import {EventStacktraceFrameFixture} from 'sentry-fixture/eventStacktraceFrame'; import {GitHubIntegrationFixture} from 'sentry-fixture/githubIntegration'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {ProjectFixture} from 'sentry-fixture/project'; @@ -63,9 +62,6 @@ describe('StackTrace', () => { const stackTraceContent = screen.getByTestId('stack-trace-content'); expect(stackTraceContent).toBeInTheDocument(); - // platform icon - expect(screen.getByTestId('platform-icon-python')).toBeInTheDocument(); - // frame list const frames = screen.getByTestId('frames'); expect(frames.children).toHaveLength(5); @@ -453,94 +449,4 @@ describe('StackTrace', () => { expect(frameTitles[1]).toHaveTextContent('raven/base.py in build_msg at line 303'); }); }); - - describe('platform icons', () => { - it('uses the top in-app frame file extension for mixed stack trace platforms', () => { - render( - - ); - - // foo.py is the most recent in-app frame with a valid file extension - expect(screen.getByTestId('platform-icon-python')).toBeInTheDocument(); - }); - - it('uses frame.platform if file extension does not work', () => { - render( - - ); - - expect(screen.getByTestId('platform-icon-node')).toBeInTheDocument(); - }); - - it('falls back to the event platform if there is no other information', () => { - render( - - ); - - expect(screen.getByTestId('platform-icon-python')).toBeInTheDocument(); - }); - }); }); diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/content.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/content.tsx index 0ff4f2d4b1023a..a3f4b8c47210c7 100644 --- a/static/app/components/events/interfaces/crashContent/stackTrace/content.tsx +++ b/static/app/components/events/interfaces/crashContent/stackTrace/content.tsx @@ -1,5 +1,4 @@ import {Fragment, useState} from 'react'; -import {css} from '@emotion/react'; import styled from '@emotion/styled'; import type {DeprecatedLineProps} from 'sentry/components/events/interfaces/frame/deprecatedLine'; @@ -9,7 +8,6 @@ import { getHiddenFrameIndices, getLastFrameIndex, isRepeatedFrame, - stackTracePlatformIcon, } from 'sentry/components/events/interfaces/utils'; import {Panel} from 'sentry/components/panels/panel'; import type {Event, Frame} from 'sentry/types/event'; @@ -18,7 +16,6 @@ import type {StackTraceMechanism, StacktraceType} from 'sentry/types/stacktrace' import {defined} from 'sentry/utils'; import {OmittedFrames} from './omittedFrames'; -import {StacktracePlatformIcon} from './platformIcon'; type DefaultProps = { expandFirstFrame: boolean; @@ -32,7 +29,6 @@ type Props = { platform: PlatformKey; className?: string; frameSourceMapDebuggerData?: FrameSourceMapDebuggerData[]; - hideIcon?: boolean; hideSourceMapDebugger?: boolean; isHoverPreviewed?: boolean; lockAddress?: string; @@ -53,7 +49,6 @@ export function Content({ isHoverPreviewed = false, maxDepth, meta, - hideIcon, threadId, lockAddress, frameSourceMapDebuggerData, @@ -202,15 +197,11 @@ export function Content({ includeSystemFrames ? 'full-traceback' : 'in-app-traceback' }`; - const platformIcon = stackTracePlatformIcon(platform, data.frames ?? []); - return ( - {hideIcon ? null : } {newestFirst ? [...convertedFrames].reverse() : convertedFrames} @@ -224,18 +215,9 @@ const Wrapper = styled('div')` position: relative; `; -export const StackTraceContentPanel = styled(Panel)<{hideIcon?: boolean}>` +export const StackTraceContentPanel = styled(Panel)` position: relative; overflow: hidden; - - ${p => - !p.hideIcon && - css` - border-top-left-radius: 0; - @media (max-width: ${p.theme.breakpoints.md}) { - border-top-left-radius: ${p.theme.radius.md}; - } - `} `; const StyledList = styled('ul')` diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/index.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/index.tsx index 73a6c278e7dc6b..c988cc0ec850ff 100644 --- a/static/app/components/events/interfaces/crashContent/stackTrace/index.tsx +++ b/static/app/components/events/interfaces/crashContent/stackTrace/index.tsx @@ -6,7 +6,6 @@ import type {PlatformKey} from 'sentry/types/project'; import type {StacktraceType} from 'sentry/types/stacktrace'; import {StackView} from 'sentry/types/stacktrace'; import {isNativePlatform} from 'sentry/utils/platform'; -import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; import {Content} from './content'; import {NativeContent} from './nativeContent'; @@ -39,7 +38,6 @@ export function StackTraceContent({ threadId, lockAddress, }: Props) { - const hasStreamlinedUI = useHasStreamlinedUI(); if (stackView === StackView.RAW) { return ( @@ -62,7 +60,6 @@ export function StackTraceContent({ groupingCurrentLevel={groupingCurrentLevel} meta={meta} inlined={inlined} - hideIcon={inlined || hasStreamlinedUI} maxDepth={maxDepth} /> @@ -79,7 +76,6 @@ export function StackTraceContent({ event={event} newestFirst={newestFirst} meta={meta} - hideIcon={inlined || hasStreamlinedUI} inlined={inlined} maxDepth={maxDepth} threadId={threadId} diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/nativeContent.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/nativeContent.tsx index 46f6ab123b16f0..a54e825e1f2d75 100644 --- a/static/app/components/events/interfaces/crashContent/stackTrace/nativeContent.tsx +++ b/static/app/components/events/interfaces/crashContent/stackTrace/nativeContent.tsx @@ -8,7 +8,6 @@ import { getLastFrameIndex, isRepeatedFrame, parseAddress, - stackTracePlatformIcon, } from 'sentry/components/events/interfaces/utils'; import {Panel} from 'sentry/components/panels/panel'; import type {Event, Frame} from 'sentry/types/event'; @@ -18,7 +17,6 @@ import type {StacktraceType} from 'sentry/types/stacktrace'; import {defined} from 'sentry/utils'; import {OmittedFrames} from './omittedFrames'; -import {StacktracePlatformIcon} from './platformIcon'; function isFrameUsedForGrouping( frame: Frame, @@ -40,7 +38,6 @@ type Props = { platform: PlatformKey; className?: string; groupingCurrentLevel?: Group['metadata']['current_level']; - hideIcon?: boolean; includeSystemFrames?: boolean; inlined?: boolean; isHoverPreviewed?: boolean; @@ -56,7 +53,6 @@ export function NativeContent({ newestFirst, isHoverPreviewed, inlined, - hideIcon, groupingCurrentLevel, includeSystemFrames = true, maxDepth, @@ -241,15 +237,9 @@ export function NativeContent({ return ( - {hideIcon ? null : ( - - )} {convertedFrames} @@ -261,9 +251,8 @@ const Wrapper = styled('div')` position: relative; `; -const ContentPanel = styled(Panel)<{hideIcon?: boolean}>` +const ContentPanel = styled(Panel)` position: relative; - border-top-left-radius: ${p => (p.hideIcon ? p.theme.radius.md : 0)}; overflow: hidden; `; diff --git a/static/app/components/events/interfaces/crashContent/stackTrace/platformIcon.tsx b/static/app/components/events/interfaces/crashContent/stackTrace/platformIcon.tsx deleted file mode 100644 index c3a47e97a34388..00000000000000 --- a/static/app/components/events/interfaces/crashContent/stackTrace/platformIcon.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import styled from '@emotion/styled'; -import {PlatformIcon} from 'platformicons'; - -type Props = { - platform: string; -}; - -export function StacktracePlatformIcon({platform}: Props) { - return ( - - ); -} - -const StyledPlatformIcon = styled(PlatformIcon)` - position: absolute; - top: 0; - right: 100%; - border-radius: 3px 0 0 3px; - - @media (max-width: ${p => p.theme.breakpoints.md}) { - display: none; - } -`; diff --git a/static/app/components/events/interfaces/exception.tsx b/static/app/components/events/interfaces/exception.tsx index bcc2b724a51035..5d99cb27c37e65 100644 --- a/static/app/components/events/interfaces/exception.tsx +++ b/static/app/components/events/interfaces/exception.tsx @@ -10,7 +10,6 @@ import {EntryType} from 'sentry/types/event'; import type {Group} from 'sentry/types/group'; import type {Project} from 'sentry/types/project'; import {SectionDivider} from 'sentry/views/issueDetails/streamline/foldSection'; -import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; import {ExceptionContent} from './crashContent/exception'; import {NoStackTraceMessage} from './noStackTraceMessage'; @@ -33,8 +32,6 @@ export function Exception({ groupingCurrentLevel, }: Props) { const eventHasThreads = !!event.entries.some(entry => entry.type === EntryType.THREADS); - const hasStreamlinedUI = useHasStreamlinedUI(); - // in case there are threads in the event data, we don't render the // exception block. Instead the exception is contained within the // thread interface. @@ -106,7 +103,7 @@ export function Exception({ groupingCurrentLevel={groupingCurrentLevel} meta={meta} /> - {hasStreamlinedUI && group && ( + {group && ( {data.values && data.values.length > 1 && ( diff --git a/static/app/components/events/interfaces/nativeFrame.tsx b/static/app/components/events/interfaces/nativeFrame.tsx index c5bf5419df6064..d1ec8b6e37c51e 100644 --- a/static/app/components/events/interfaces/nativeFrame.tsx +++ b/static/app/components/events/interfaces/nativeFrame.tsx @@ -46,7 +46,6 @@ import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageStat import {withSentryAppComponents} from 'sentry/utils/withSentryAppComponents'; import {SectionKey, useIssueDetails} from 'sentry/views/issueDetails/streamline/context'; import {getFoldSectionKey} from 'sentry/views/issueDetails/streamline/foldSection'; -import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; import {combineStatus} from './debugMeta/utils'; import {Context} from './frame/context'; @@ -112,8 +111,6 @@ function NativeFrame({ getFoldSectionKey(SectionKey.DEBUGMETA), debugSectionConfig?.initialCollapse ?? false ); - const hasStreamlinedUI = useHasStreamlinedUI(); - const fullStackTrace = stackView === StackView.FULL; const absolute = displayOptions.includes('absolute-addresses'); @@ -127,8 +124,7 @@ function NativeFrame({ !!frame.symbolicatorStatus && frame.symbolicatorStatus !== SymbolicatorStatus.UNKNOWN_IMAGE && !isHoverPreviewed && - // We know the debug section is rendered (only once streamline ui is enabled) - (hasStreamlinedUI ? !!debugSectionConfig : true); + !!debugSectionConfig; const leadsToApp = !frame.inApp && (nextFrame?.inApp || !nextFrame); const expandable = isExpandable({ @@ -255,10 +251,8 @@ function NativeFrame({ DebugMetaStore.updateFilter(searchTerm); } - if (hasStreamlinedUI) { - // Expand the section - setIsCollapsed(false); - } + // Expand the section + setIsCollapsed(false); // Scroll to the section document diff --git a/static/app/components/events/interfaces/request/index.tsx b/static/app/components/events/interfaces/request/index.tsx index 0803cab1dd2775..40f3864d90f816 100644 --- a/static/app/components/events/interfaces/request/index.tsx +++ b/static/app/components/events/interfaces/request/index.tsx @@ -7,15 +7,16 @@ import {ExternalLink} from '@sentry/scraps/link'; import {SegmentedControl} from '@sentry/scraps/segmentedControl'; import {Text} from '@sentry/scraps/text'; -import {ClippedBox} from 'sentry/components/clippedBox'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import {EventDataSection} from 'sentry/components/events/eventDataSection'; +import {KeyValueList} from 'sentry/components/events/interfaces/keyValueList'; import {GraphQlRequestBody} from 'sentry/components/events/interfaces/request/graphQlRequestBody'; import {getCurlCommand, getFullUrl} from 'sentry/components/events/interfaces/utils'; import { KeyValueData, type KeyValueDataContentProps, } from 'sentry/components/keyValueData'; +import {StructuredEventData} from 'sentry/components/structuredEventData'; +import {JsonEventData} from 'sentry/components/structuredEventData/jsonEventData'; import {Truncate} from 'sentry/components/truncate'; import {IconOpen} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; @@ -25,13 +26,8 @@ import {defined} from 'sentry/utils'; import {isUrl} from 'sentry/utils/string/isUrl'; import {SectionKey} from 'sentry/views/issueDetails/streamline/context'; import {FoldSection} from 'sentry/views/issueDetails/streamline/foldSection'; -import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; -import { - getBodyContent, - RichHttpContentClippedBoxBodySection, -} from './richHttpContentClippedBoxBodySection'; -import {RichHttpContentClippedBoxKeyValueList} from './richHttpContentClippedBoxKeyValueList'; +import {getTransformedData} from './getTransformedData'; interface RequestProps { data: EntryRequest['data']; @@ -44,49 +40,86 @@ interface RequestBodyProps extends RequestProps { type View = 'formatted' | 'curl'; -function RequestBodySection({data, event, meta}: RequestBodyProps) { - const hasStreamlinedUI = useHasStreamlinedUI(); +function getBodyContent({ + data, + meta, + inferredContentType, +}: { + data: EntryRequest['data']['data']; + inferredContentType: EntryRequest['data']['inferredContentType']; + meta: Record | undefined; +}) { + switch (inferredContentType) { + case 'application/json': + return ( + + ); + case 'application/x-www-form-urlencoded': + case 'multipart/form-data': { + const transformedData = getTransformedData(data, meta).map(d => { + const [key, value] = d.data; + return { + key, + subject: key, + value, + meta: d.meta, + }; + }); + if (!transformedData.length) { + return null; + } + + return ( + + ); + } + + default: + return ( +
+          
+        
+ ); + } +} + +function RequestBodySection({data, event, meta}: RequestBodyProps) { if (!defined(data.data)) { return null; } if (data.apiTarget === 'graphql' && typeof data.data.query === 'string') { - return hasStreamlinedUI ? ( - - {t('Body')} - - - ) : ( - - ); - } - - if (hasStreamlinedUI) { - const contentBody = getBodyContent({ - data: data.data, - meta: meta?.data, - inferredContentType: data.inferredContentType, - }); return ( {t('Body')} - {contentBody} + ); } + const contentBody = getBodyContent({ + data: data.data, + meta: meta?.data, + inferredContentType: data.inferredContentType, + }); return ( - + + {t('Body')} + {contentBody} + ); } export function Request({data, event}: RequestProps) { - const hasStreamlinedUI = useHasStreamlinedUI(); const entryIndex = event.entries.findIndex(entry => entry.type === EntryType.REQUEST); const meta = event._meta?.entries?.[entryIndex]?.data; @@ -133,99 +166,38 @@ export function Request({data, event}: RequestProps) { ); - if (hasStreamlinedUI) { - return ( - - {title} - {view === 'curl' ? ( - {getCurlCommand(data)} - ) : ( - - - - - - - - - )} - - ); - } - return ( - + {title} {view === 'curl' ? ( {getCurlCommand(data)} ) : ( - {defined(data.query) && ( - - )} - {defined(data.fragment) && ( - - -
{data.fragment}
-
-
- )} - - {defined(data.cookies) && Object.keys(data.cookies).length > 0 && ( - - )} - {defined(data.headers) && ( - - )} - {defined(data.env) && ( - - )} + + + + + +
)} -
+ ); } diff --git a/static/app/components/events/interfaces/request/richHttpContentClippedBoxBodySection.tsx b/static/app/components/events/interfaces/request/richHttpContentClippedBoxBodySection.tsx deleted file mode 100644 index 3f2f40293f1835..00000000000000 --- a/static/app/components/events/interfaces/request/richHttpContentClippedBoxBodySection.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import {ClippedBox} from 'sentry/components/clippedBox'; -import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import {KeyValueList} from 'sentry/components/events/interfaces/keyValueList'; -import {StructuredEventData} from 'sentry/components/structuredEventData'; -import {JsonEventData} from 'sentry/components/structuredEventData/jsonEventData'; -import {t} from 'sentry/locale'; -import type {EntryRequest} from 'sentry/types/event'; -import {defined} from 'sentry/utils'; - -import {getTransformedData} from './getTransformedData'; - -type Props = { - data: EntryRequest['data']['data']; - inferredContentType: EntryRequest['data']['inferredContentType']; - meta: Record | undefined; -}; - -export function getBodyContent({data, meta, inferredContentType}: Props) { - switch (inferredContentType) { - case 'application/json': - return ( - - ); - case 'application/x-www-form-urlencoded': - case 'multipart/form-data': { - const transformedData = getTransformedData(data, meta).map(d => { - const [key, value] = d.data; - return { - key, - subject: key, - value, - meta: d.meta, - }; - }); - - if (!transformedData.length) { - return null; - } - - return ( - - ); - } - - default: - return ( -
-          
-        
- ); - } -} - -export function RichHttpContentClippedBoxBodySection({ - data, - meta, - inferredContentType, -}: Props) { - if (!defined(data)) { - return null; - } - - const content = getBodyContent({data, meta, inferredContentType}); - - if (!content) { - return null; - } - - return ( - - {content} - - ); -} diff --git a/static/app/components/events/interfaces/request/richHttpContentClippedBoxKeyValueList.tsx b/static/app/components/events/interfaces/request/richHttpContentClippedBoxKeyValueList.tsx deleted file mode 100644 index 56cfc23a7449ef..00000000000000 --- a/static/app/components/events/interfaces/request/richHttpContentClippedBoxKeyValueList.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import {ClippedBox} from 'sentry/components/clippedBox'; -import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import {KeyValueList} from 'sentry/components/events/interfaces/keyValueList'; -import type {EntryRequest} from 'sentry/types/event'; -import type {Meta} from 'sentry/types/group'; -import {defined} from 'sentry/utils'; - -import {getTransformedData} from './getTransformedData'; - -type Data = EntryRequest['data']['data']; - -type Props = { - data: Data; - title: string; - defaultCollapsed?: boolean; - isContextData?: boolean; - meta?: Meta; -}; - -export function RichHttpContentClippedBoxKeyValueList({ - data, - title, - defaultCollapsed = false, - isContextData = false, - meta, -}: Props) { - const transformedData = getTransformedData(data, meta); - - function getContent() { - // Sentry API abbreviates long query string values, sometimes resulting in - // an un-parsable querystring ... stay safe kids - try { - return ( - { - const [key, value] = d.data; - - if (!value && !d.meta) { - return null; - } - - return { - key, - subject: key, - value, - meta: d.meta, - }; - }) - .filter(defined)} - isContextData={isContextData} - /> - ); - } catch { - // TODO(TS): Types indicate that data might be an object - return
{data as any}
; - } - } - - if (!transformedData.length) { - return null; - } - - return ( - - {getContent()} - - ); -} diff --git a/static/app/components/events/interfaces/threads.tsx b/static/app/components/events/interfaces/threads.tsx index 3d3bde5dec37dc..840758a5987282 100644 --- a/static/app/components/events/interfaces/threads.tsx +++ b/static/app/components/events/interfaces/threads.tsx @@ -39,7 +39,6 @@ import {defined} from 'sentry/utils'; import {SectionKey} from 'sentry/views/issueDetails/streamline/context'; import {setActiveThreadId} from 'sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails'; import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection'; -import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils'; import {ExceptionContent} from './crashContent/exception'; import {StackTraceContent} from './crashContent/stackTrace'; @@ -174,7 +173,6 @@ export function Threads({data, event, projectSlug, groupingCurrentLevel, group}: () => (data.values ?? []).toSorted((a, b) => Number(b.crashed) - Number(a.crashed)), [data.values] ); - const hasStreamlinedUI = useHasStreamlinedUI(); const [activeThread, setActiveThread] = useActiveThreadState(event, threads); // Sync active thread to module store for copy functionality @@ -387,7 +385,7 @@ export function Threads({data, event, projectSlug, groupingCurrentLevel, group}: exception={exception} platform={platform} /> - {hasStreamlinedUI && group && ( + {group && ( ); - if (hasStreamlinedUI) { - // If there is only one thread, we expect the stacktrace to wrap itself in a section - return hasMoreThanOneThread ? ( - - - {threadComponent} - - - ) : ( - threadComponent - ); - } - + // If there is only one thread, we expect the stacktrace to wrap itself in a section return hasMoreThanOneThread ? ( - {threadComponent} + + + {threadComponent} + + ) : ( threadComponent ); @@ -447,16 +437,6 @@ const LockReason = styled(TextOverflow)` color: ${p => p.theme.tokens.content.secondary}; `; -const ThreadTraceWrapper = styled('div')` - display: flex; - flex-direction: column; - gap: ${p => p.theme.space.xl}; - padding: ${p => p.theme.space.md} ${p => p.theme.space['3xl']}; - @media (max-width: ${p => p.theme.breakpoints.md}) { - padding: ${p => p.theme.space.md} ${p => p.theme.space.xl}; - } -`; - const ThreadHeading = styled('h3')` color: ${p => p.theme.tokens.content.secondary}; font-size: ${p => p.theme.font.size.md}; diff --git a/static/app/components/events/interfaces/utils.tsx b/static/app/components/events/interfaces/utils.tsx index a1b5b18ee6400d..32182e6b382685 100644 --- a/static/app/components/events/interfaces/utils.tsx +++ b/static/app/components/events/interfaces/utils.tsx @@ -1,4 +1,3 @@ -import partition from 'lodash/partition'; import * as qs from 'query-string'; import {getThreadException} from 'sentry/components/events/interfaces/threads/threadSelector/getThreadException'; @@ -11,7 +10,6 @@ import type {PlatformKey} from 'sentry/types/project'; import type {StacktraceType} from 'sentry/types/stacktrace'; import {StacktraceOrder, type AvatarUser} from 'sentry/types/user'; import {defined} from 'sentry/utils'; -import {fileExtensionToPlatform, getFileExtension} from 'sentry/utils/fileExtension'; /** * Attempts to escape a string from any bash double quote special characters. @@ -299,50 +297,6 @@ export function parseAssembly(assembly: string | null) { return {name, version, culture, publicKeyToken}; } -function getFramePlatform(frame: Frame) { - const fileExtension = getFileExtension(frame.filename ?? ''); - const fileExtensionPlatform = fileExtension - ? fileExtensionToPlatform(fileExtension) - : null; - - if (fileExtensionPlatform) { - return fileExtensionPlatform; - } - - if (frame.platform) { - return frame.platform; - } - - return null; -} - -/** - * Returns the representative platform for the given stack trace frames. - * Prioritizes recent in-app frames, checking first for a matching file extension - * and then for a frame.platform attribute [1]. - * - * If none of the frames have a platform, falls back to the event platform. - * - * [1] https://develop.sentry.dev/sdk/event-payloads/stacktrace/#frame-attributes - */ -export function stackTracePlatformIcon(eventPlatform: PlatformKey, frames: Frame[]) { - const [inAppFrames, systemFrames] = partition( - // Reverse frames to get newest-first ordering - [...frames].reverse(), - frame => frame.inApp - ); - - for (const frame of [...inAppFrames, ...systemFrames]) { - const framePlatform = getFramePlatform(frame); - - if (framePlatform) { - return framePlatform; - } - } - - return eventPlatform; -} - export function isStacktraceNewestFirst() { const user = ConfigStore.get('user'); // user may not be authenticated diff --git a/static/app/components/feedback/feedbackOnboarding/sidebar.tsx b/static/app/components/feedback/feedbackOnboarding/sidebar.tsx index 4103268c11fc09..4c07f415670005 100644 --- a/static/app/components/feedback/feedbackOnboarding/sidebar.tsx +++ b/static/app/components/feedback/feedbackOnboarding/sidebar.tsx @@ -1,13 +1,14 @@ import type {ReactNode} from 'react'; import {Fragment, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; +import {parseAsStringLiteral, useQueryState} from 'nuqs'; import {PlatformIcon} from 'platformicons'; import HighlightTopRightPattern from 'sentry-images/pattern/highlight-top-right.svg'; import {LinkButton} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; -import {Flex} from '@sentry/scraps/layout'; +import {Container, Flex} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {FeedbackOnboardingLayout} from 'sentry/components/feedback/feedbackOnboarding/feedbackOnboardingLayout'; @@ -42,7 +43,6 @@ import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import type {SelectValue} from 'sentry/types/core'; import type {PlatformKey, Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -197,13 +197,12 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { textValue?: string; }>(jsFrameworkSelectOptions[0]!); - const defaultTab = 'npm'; const location = useLocation(); const crashReportOnboarding = location.hash === CRASH_REPORT_HASH; - const {getParamValue: setupMode, setParamValue: setSetupMode} = useUrlParams( + const [setupMode, setSetupMode] = useQueryState( 'mode', - defaultTab + parseAsStringLiteral(['npm', 'jsLoader'] as const).withDefault('npm') ); const currentPlatform = currentProject.platform @@ -211,7 +210,7 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { : otherPlatform; const webBackendPlatform = replayBackendPlatforms.includes(currentPlatform.id); - const showJsFrameworkInstructions = webBackendPlatform && setupMode() === 'npm'; + const showJsFrameworkInstructions = webBackendPlatform && setupMode === 'npm'; const crashApiPlatform = feedbackCrashApiPlatforms.includes(currentPlatform.id); const widgetPlatform = feedbackWidgetPlatforms.includes(currentPlatform.id); @@ -261,48 +260,50 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { const radioButtons = (
{showRadioButtons ? ( - - {tct('I use [platformSelect]', { - platformSelect: ( - ( - - {jsFramework.label ?? triggerProps.children} - - )} - value={jsFramework.value} - onChange={setJsFramework} - options={jsFrameworkSelectOptions} - position="bottom-end" - key={jsFramework.textValue} - disabled={setupMode() === 'jsLoader'} + + + label="mode" + choices={[ + [ + 'npm', + webBackendPlatform ? ( + + {tct('I use [platformSelect]', { + platformSelect: ( + ( + + {jsFramework.label ?? triggerProps.children} + + )} + value={jsFramework.value} + onChange={setJsFramework} + options={jsFrameworkSelectOptions} + position="bottom-end" + key={jsFramework.textValue} + disabled={setupMode === 'jsLoader'} + /> + ), + })} + {jsFrameworkDocs?.platformOptions && ( + - ), - })} - {jsFrameworkDocs?.platformOptions && ( - - )} - - ) : ( - t('I use NPM or Yarn') - ), - ], - ['jsLoader', t('I use HTML templates (Loader Script)')], - ]} - value={setupMode()} - onChange={setSetupMode} - tooltipPosition="top-start" - /> + )} + + ) : ( + t('I use NPM or Yarn') + ), + ], + ['jsLoader', t('I use HTML templates (Loader Script)')], + ]} + value={setupMode} + onChange={value => setSetupMode(value)} + tooltipPosition="top-start" + /> + ) : ( (newDocs?.platformOptions?.siblingOption || newDocs?.platformOptions?.packageManager) && @@ -370,9 +371,8 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { return 'feedbackOnboardingCrashApi'; } if ( - setupMode() === 'npm' || // switched to NPM option - (!setupMode() && defaultTab === 'npm' && widgetPlatform) || // default value for FE frameworks when ?mode={...} in URL is not set yet - npmOnlyFramework // even if '?mode=jsLoader', only show npm instructions for FE frameworks) + setupMode === 'npm' || // switched to NPM option + npmOnlyFramework // even if '?mode=jsLoader', only show npm instructions for FE frameworks ) { return 'feedbackOnboardingNpm'; } @@ -431,7 +431,3 @@ const StyledIdBadge = styled(IdBadge)` white-space: nowrap; flex-shrink: 1; `; - -const StyledRadioGroup = styled(RadioGroup)` - padding: ${p => p.theme.space.md} 0; -`; diff --git a/static/app/components/globalModal/index.tsx b/static/app/components/globalModal/index.tsx index ac6317af7c669e..50645446279f98 100644 --- a/static/app/components/globalModal/index.tsx +++ b/static/app/components/globalModal/index.tsx @@ -14,7 +14,6 @@ import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal'; import {ROOT_ELEMENT} from 'sentry/constants'; import {ModalStore} from 'sentry/stores/modalStore'; import {getModalPortal} from 'sentry/utils/getModalPortal'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useEffectAfterFirstRender} from 'sentry/utils/useEffectAfterFirstRender'; import {useLocation} from 'sentry/utils/useLocation'; import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; @@ -265,11 +264,11 @@ export function GlobalModal({onClose}: Props) { transition={ hasPageFrame ? theme.motion.framer.enter.moderate - : testableTransition({ + : { type: 'spring', stiffness: 450, damping: 25, - }) + } } > diff --git a/static/app/components/group/groupSummary.tsx b/static/app/components/group/groupSummary.tsx index 25d76fcac2c404..52eb53b6b3a9fa 100644 --- a/static/app/components/group/groupSummary.tsx +++ b/static/app/components/group/groupSummary.tsx @@ -26,7 +26,6 @@ import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig'; import {MarkedText} from 'sentry/utils/marked/markedText'; import {useApiQuery, useQueryClient, type ApiQueryKey} from 'sentry/utils/queryClient'; import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig'; @@ -306,13 +305,13 @@ function GroupSummaryCollapsed({ >; if (isNativePlatform(platform)) { - return ( - - ); + return ; } - return ; + return ; } type StackTracePreviewProps = { diff --git a/static/app/components/illustrations/NoProjectEmptyState.tsx b/static/app/components/illustrations/NoProjectEmptyState.tsx index e62930e7936916..a68745516d4f3a 100644 --- a/static/app/components/illustrations/NoProjectEmptyState.tsx +++ b/static/app/components/illustrations/NoProjectEmptyState.tsx @@ -1,16 +1,16 @@ import {css, keyframes} from '@emotion/react'; import styled from '@emotion/styled'; +import type {Transition} from 'framer-motion'; import {motion} from 'framer-motion'; import {makeOpacityJitter, makeShake} from 'sentry/styles/animations'; -import {testableTransition} from 'sentry/utils/testableTransition'; const Background = styled(motion.g)``; const backgroundAnimationProps = { initial: {opacity: 0}, animate: {opacity: 1}, - transition: testableTransition({duration: 0.5}), + transition: {duration: 0.5}, }; const MainWindows = styled(motion.g)``; @@ -18,7 +18,12 @@ const MainWindows = styled(motion.g)``; const mainWindowsAnimationProps = { initial: {rotate: -2, scale: 0.95, opacity: 0}, animate: {rotate: 0, scale: 1, opacity: 1}, - transition: testableTransition({type: 'spring', delay: 0.2, stiffness: 200, bounce: 5}), + transition: { + type: 'spring', + delay: 0.2, + stiffness: 200, + bounce: 5, + } satisfies Transition, }; const SecondWindow = styled(motion.g)``; @@ -26,7 +31,12 @@ const SecondWindow = styled(motion.g)``; const secondWindowAnimationProps = { initial: {rotate: 10, scale: 0.9, opacity: 0}, animate: {rotate: 0, scale: 1, opacity: 1}, - transition: testableTransition({type: 'spring', delay: 0.7, stiffness: 200, bounce: 5}), + transition: { + type: 'spring', + delay: 0.7, + stiffness: 200, + bounce: 5, + } satisfies Transition, }; const Alert = styled(motion.g)` @@ -52,7 +62,7 @@ const CablePlug = styled(Cable)``; const cablePlugAnimationProps = { ...cableAnimate, style: {'--stroke-length': 717.05} as any, - transition: testableTransition({duration: 2, delay: 1.3}), + transition: {duration: 2, delay: 1.3}, }; const CableWindow1 = styled(Cable)``; @@ -60,21 +70,26 @@ const cableWindow1AnimationProps = { ...cableAnimate, custom: {length: 89.28}, style: {'--stroke-length': 89.28} as any, - transition: testableTransition({duration: 0.8, delay: 0.9}), + transition: {duration: 0.8, delay: 0.9}, }; const CableWindow2 = styled(Cable)``; const cableWindow2AnimationProps = { ...cableAnimate, style: {'--stroke-length': 132.26} as any, - transition: testableTransition({duration: 0.9, delay: 1}), + transition: {duration: 0.9, delay: 1}, }; const PowerBolts = styled(motion.g)``; const powerBoltsAnimationProps = { initial: {rotate: 10, scale: 0.9, opacity: 0}, animate: {rotate: 0, scale: 1, opacity: 1}, - transition: testableTransition({type: 'spring', delay: 3.2, stiffness: 200, bounce: 5}), + transition: { + type: 'spring', + delay: 3.2, + stiffness: 200, + bounce: 5, + } satisfies Transition, }; const PowerBolt = styled(motion.g)` @@ -89,11 +104,11 @@ const sparksAnimationProps = { variants: { animate: { x: 0, - transition: testableTransition({ + transition: { when: 'beforeChildren', delay: 3.6, staggerChildren: 0.2, - }), + }, }, }, }; @@ -118,7 +133,7 @@ const sparkAnimationProps = { display: 'block', }, }, - transition: testableTransition({type: 'spring', stiffness: 500, bounce: 80}), + transition: {type: 'spring', stiffness: 500, bounce: 80} satisfies Transition, }; const oilSpill = keyframes` diff --git a/static/app/components/markdownTextArea.tsx b/static/app/components/markdownTextArea.tsx new file mode 100644 index 00000000000000..6741807531d360 --- /dev/null +++ b/static/app/components/markdownTextArea.tsx @@ -0,0 +1,29 @@ +import styled from '@emotion/styled'; + +import {Container} from '@sentry/scraps/layout'; +import type {TextAreaProps} from '@sentry/scraps/textarea'; +import {TextArea} from '@sentry/scraps/textarea'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {IconMarkdown} from 'sentry/icons'; +import {t} from 'sentry/locale'; +interface MarkdownTextAreaProps extends TextAreaProps { + className?: string; +} + +export function MarkdownTextArea({className, ...props}: MarkdownTextAreaProps) { + return ( + + + + + + + + + ); +} + +const RightPaddedTextArea = styled(TextArea)` + padding-right: ${p => p.theme.space['2xl']}; +`; diff --git a/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.spec.tsx b/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.spec.tsx index 1fede2a07f7ec9..6efaad65e9d54f 100644 --- a/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.spec.tsx +++ b/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.spec.tsx @@ -1,6 +1,7 @@ import {createMemoryRouter, RouterProvider} from 'react-router-dom'; import {ProjectFixture} from 'sentry-fixture/project'; +import {SentryNuqsTestingAdapter} from 'sentry-test/nuqsTestingAdapter'; import {act, renderHook} from 'sentry-test/reactTestingLibrary'; import {useCurrentProjectState} from 'sentry/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState'; @@ -16,14 +17,15 @@ import type {Project} from 'sentry/types/project'; function createWrapper(projectSlug?: string) { return function Wrapper({children}: any) { + const wrapped = {children}; const memoryRouter = createMemoryRouter([ { path: '/', - element: children, + element: wrapped, }, { path: '/:projectId/', - element: children, + element: wrapped, }, ]); diff --git a/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.ts b/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.ts index c1649f331ee07c..8d9b9ef80dc052 100644 --- a/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.ts +++ b/static/app/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState.ts @@ -1,12 +1,12 @@ import {useCallback, useEffect, useMemo, useState} from 'react'; import partition from 'lodash/partition'; +import {parseAsString, useQueryState} from 'nuqs'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; import type {OnboardingDrawerKey} from 'sentry/stores/onboardingDrawerStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import type {PlatformKey, Project} from 'sentry/types/project'; import {getSelectedProjectList} from 'sentry/utils/project/useSelectedProjectsHaveField'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {useProjects} from 'sentry/utils/useProjects'; type Props = { @@ -24,8 +24,7 @@ export function useCurrentProjectState({ }: Props) { const {projects, initiallyLoaded: projectsLoaded} = useProjects(); const {selection, isReady} = useLegacyStore(PageFiltersStore); - const {getParamValue: projectIds} = useUrlParams('project'); - const projectId = projectIds()?.split('&').at(0); + const [projectId] = useQueryState('project', parseAsString); const isActive = currentPanel === targetPanel; // Projects with onboarding instructions diff --git a/static/app/components/overlay.tsx b/static/app/components/overlay.tsx index 499e34b97e8230..d30c94e31bda84 100644 --- a/static/app/components/overlay.tsx +++ b/static/app/components/overlay.tsx @@ -9,7 +9,6 @@ import {OverlayArrow} from 'sentry/components/overlayArrow'; import {NODE_ENV} from 'sentry/constants'; import {defined} from 'sentry/utils'; import {PanelProvider} from 'sentry/utils/panelProvider'; -import {testableTransition} from 'sentry/utils/testableTransition'; type OriginPoint = Partial<{x: number; y: number}>; @@ -48,15 +47,15 @@ const overlayAnimation: MotionProps = { animate: { opacity: 1, scale: 1, - transition: testableTransition({ + transition: { type: 'spring', duration: 0.2, - }), + }, }, exit: { opacity: 0, scale: 0.95, - transition: testableTransition({type: 'spring', delay: 0.1}), + transition: {type: 'spring', delay: 0.1}, }, }; diff --git a/static/app/components/pageOverlay.tsx b/static/app/components/pageOverlay.tsx index 7ffb241e38929d..2429dcef2828ec 100644 --- a/static/app/components/pageOverlay.tsx +++ b/static/app/components/pageOverlay.tsx @@ -1,12 +1,12 @@ import {useEffect, useRef} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; +import type {Transition, Variants} from 'framer-motion'; import {motion} from 'framer-motion'; import {Prose} from '@sentry/scraps/text'; import {Panel} from 'sentry/components/panels/panel'; -import {testableTransition} from 'sentry/utils/testableTransition'; /** * The default wrapper for the detail text. @@ -18,7 +18,7 @@ const DefaultWrapper = styled('div')` width: 500px; `; -const subItemAnimation = { +const subItemAnimation: Variants = { initial: { opacity: 0, x: 60, @@ -26,15 +26,15 @@ const subItemAnimation = { animate: { opacity: 1, x: 0, - transition: testableTransition({ + transition: { type: 'spring', duration: 0.4, - }), + }, }, }; const Header = styled((props: React.ComponentProps) => ( - + ))` display: flex; align-items: center; @@ -43,7 +43,7 @@ const Header = styled((props: React.ComponentProps) => ( `; const Body = styled((props: React.ComponentProps) => ( - + ))` margin-bottom: ${p => p.theme.space.xl}; `; @@ -190,13 +190,13 @@ export function PageOverlay({ const Wrapper = customWrapper ?? DefaultWrapper; - const transition = testableTransition({ + const transition: Transition = { delay: 1, duration: 1.2, ease: 'easeInOut', delayChildren: animateDelay ?? (BackgroundComponent ? 0.5 : 1.5), staggerChildren: 0.15, - }); + }; return ( diff --git a/static/app/components/progressRing.tsx b/static/app/components/progressRing.tsx index 4197c97bddf566..290d7181b0db49 100644 --- a/static/app/components/progressRing.tsx +++ b/static/app/components/progressRing.tsx @@ -3,8 +3,6 @@ import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {AnimatePresence, motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - type TextProps = { percent: number; theme: Theme; @@ -67,7 +65,6 @@ const animatedTextDefaultProps = { initial: {opacity: 0, y: -10}, animate: {opacity: 1, y: 0}, exit: {opacity: 0, y: 10}, - transition: testableTransition(), }; export function ProgressRing({ diff --git a/static/app/components/replays/virtualizedGrid/useDetailsSplit.tsx b/static/app/components/replays/virtualizedGrid/useDetailsSplit.tsx index 030ff2a8aa7760..df947f99345f0b 100644 --- a/static/app/components/replays/virtualizedGrid/useDetailsSplit.tsx +++ b/static/app/components/replays/virtualizedGrid/useDetailsSplit.tsx @@ -1,7 +1,7 @@ import type {RefObject} from 'react'; import {useCallback} from 'react'; +import {parseAsInteger, useQueryState} from 'nuqs'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {useResizableDrawer} from 'sentry/utils/useResizableDrawer'; interface OnClickProps { @@ -26,26 +26,23 @@ export function useDetailsSplit({ onShowDetails, urlParamName, }: Props) { - const {getParamValue: getDetailIndex, setParamValue: setDetailIndex} = useUrlParams( - urlParamName, - '' - ); + const [detailIndex, setDetailIndex] = useQueryState(urlParamName, parseAsInteger); const onClickCell = useCallback( ({dataIndex, rowIndex}: OnClickProps) => { - if (getDetailIndex() === String(dataIndex)) { - setDetailIndex(''); + if (detailIndex === dataIndex) { + setDetailIndex(null); onHideDetails?.(); } else { - setDetailIndex(String(dataIndex)); + setDetailIndex(dataIndex); onShowDetails?.({dataIndex, rowIndex}); } }, - [getDetailIndex, setDetailIndex, onHideDetails, onShowDetails] + [detailIndex, setDetailIndex, onHideDetails, onShowDetails] ); const onCloseDetailsSplit = useCallback(() => { - setDetailIndex(''); + setDetailIndex(null); onHideDetails?.(); }, [setDetailIndex, onHideDetails]); @@ -63,13 +60,15 @@ export function useDetailsSplit({ const maxContainerHeight = (containerRef.current?.clientHeight || window.innerHeight) - handleHeight; const splitSize = - frames && getDetailIndex() ? Math.min(maxContainerHeight, containerSize) : undefined; + frames && detailIndex !== null + ? Math.min(maxContainerHeight, containerSize) + : undefined; return { onClickCell, onCloseDetailsSplit, resizableDrawerProps, - selectedIndex: getDetailIndex(), + selectedIndex: detailIndex, splitSize, }; } diff --git a/static/app/components/replaysOnboarding/sidebar.tsx b/static/app/components/replaysOnboarding/sidebar.tsx index 6a6e0e7b3ec0b3..4d1c2699c8cf7e 100644 --- a/static/app/components/replaysOnboarding/sidebar.tsx +++ b/static/app/components/replaysOnboarding/sidebar.tsx @@ -1,13 +1,14 @@ import type {ReactNode} from 'react'; import {Fragment, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; +import {parseAsStringLiteral, useQueryState} from 'nuqs'; import {PlatformIcon} from 'platformicons'; import HighlightTopRightPattern from 'sentry-images/pattern/highlight-top-right.svg'; import {LinkButton} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; -import {Flex} from '@sentry/scraps/layout'; +import {Container, Flex} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {RadioGroup} from 'sentry/components/forms/controls/radioGroup'; @@ -38,7 +39,6 @@ import { import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import type {SelectValue} from 'sentry/types/core'; import type {PlatformKey, Project} from 'sentry/types/project'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {useOrganization} from 'sentry/utils/useOrganization'; export function useReplaysOnboardingDrawer() { @@ -223,13 +223,12 @@ function OnboardingContent({ .filter((p): p is PlatformKey => p !== 'javascript') .includes(currentProject.platform); - const defaultTab = 'jsLoader'; - const {getParamValue: setupMode, setParamValue: setSetupMode} = useUrlParams( + const [setupMode, setSetupMode] = useQueryState( 'mode', - defaultTab + parseAsStringLiteral(['npm', 'jsLoader'] as const).withDefault('jsLoader') ); - const showJsFrameworkInstructions = backendPlatform && setupMode() === 'npm'; + const showJsFrameworkInstructions = backendPlatform && setupMode === 'npm'; const currentPlatform = currentProject.platform ? (platforms.find(p => p.id === currentProject.platform) ?? otherPlatform) @@ -243,7 +242,7 @@ function OnboardingContent({ projectKeyId, } = useLoadGettingStarted({ platform: - showJsFrameworkInstructions && setupMode() === 'npm' + showJsFrameworkInstructions && setupMode === 'npm' ? (replayJsFrameworkOptions().find(p => p.id === jsFramework.value) ?? replayJsFrameworkOptions()[0]!) : currentPlatform, @@ -269,47 +268,49 @@ function OnboardingContent({ const radioButtons = (
{showRadioButtons ? ( - - {tct('I use [platformSelect]', { - platformSelect: ( - ( - - {jsFramework.label ?? triggerProps.children} - - )} - value={jsFramework.value} - onChange={setJsFramework} - options={jsFrameworkSelectOptions} - position="bottom-end" - key={jsFramework.textValue} - disabled={setupMode() === 'jsLoader'} + + + label="mode" + choices={[ + [ + 'npm', + backendPlatform ? ( + + {tct('I use [platformSelect]', { + platformSelect: ( + ( + + {jsFramework.label ?? triggerProps.children} + + )} + value={jsFramework.value} + onChange={setJsFramework} + options={jsFrameworkSelectOptions} + position="bottom-end" + key={jsFramework.textValue} + disabled={setupMode === 'jsLoader'} + /> + ), + })} + {jsFrameworkDocs?.platformOptions && ( + - ), - })} - {jsFrameworkDocs?.platformOptions && ( - - )} - - ) : ( - t('I use NPM or Yarn') - ), - ], - ['jsLoader', t('I use HTML templates (Loader Script)')], - ]} - value={setupMode()} - onChange={setSetupMode} - /> + )} + + ) : ( + t('I use NPM or Yarn') + ), + ], + ['jsLoader', t('I use HTML templates (Loader Script)')], + ]} + value={setupMode} + onChange={value => setSetupMode(value)} + /> + ) : ( !mobilePlatform && (docs?.platformOptions?.siblingOption || docs?.platformOptions?.packageManager) && @@ -405,7 +406,7 @@ function OnboardingContent({ platformKey={currentPlatform.id} project={currentProject} configType={ - setupMode() === 'npm' || // switched to NPM option + setupMode === 'npm' || // switched to NPM option npmOnlyFramework || mobilePlatform // even if '?mode=jsLoader', only show npm/default instructions for FE frameworks & mobile platforms ? 'replayOnboarding' @@ -452,7 +453,3 @@ const StyledIdBadge = styled(IdBadge)` white-space: nowrap; flex-shrink: 1; `; - -const StyledRadioGroup = styled(RadioGroup)` - padding: ${p => p.theme.space.md} 0; -`; diff --git a/static/app/components/stream/supergroupRow.tsx b/static/app/components/stream/supergroupRow.tsx index dff3ee828bbbf9..d0c1b0a972ce49 100644 --- a/static/app/components/stream/supergroupRow.tsx +++ b/static/app/components/stream/supergroupRow.tsx @@ -2,6 +2,7 @@ import {useState} from 'react'; import styled from '@emotion/styled'; import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; +import {Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import type {IndexedMembersByProject} from 'sentry/actionCreators/members'; @@ -13,8 +14,8 @@ import {Placeholder} from 'sentry/components/placeholder'; import {TimeSince} from 'sentry/components/timeSince'; import {IconStack} from 'sentry/icons'; import {t} from 'sentry/locale'; -import type {AggregatedSupergroupStats} from 'sentry/utils/supergroup/aggregateSupergroupStats'; import {COLUMN_BREAKPOINTS} from 'sentry/views/issueList/actions/utils'; +import type {AggregatedSupergroupStats} from 'sentry/views/issueList/supergroups/aggregateSupergroupStats'; import {SupergroupDetailDrawer} from 'sentry/views/issueList/supergroups/supergroupDrawer'; import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; @@ -106,7 +107,17 @@ export function SupergroupRow({ {aggregatedStats?.mergedStats && aggregatedStats.mergedStats.length > 0 ? ( - + ) : ( )} @@ -114,7 +125,14 @@ export function SupergroupRow({ {aggregatedStats ? ( - + + + {aggregatedStats.filteredEventCount !== null && ( + + )} + ) : ( )} @@ -122,7 +140,14 @@ export function SupergroupRow({ {aggregatedStats ? ( - + + + {aggregatedStats.filteredUserCount !== null && ( + + )} + ) : ( )} @@ -258,6 +283,14 @@ const PrimaryCount = styled(Count)` font-variant-numeric: tabular-nums; `; +const SecondaryCount = styled(Count)` + font-size: ${p => p.theme.font.size.sm}; + display: flex; + justify-content: flex-end; + color: ${p => p.theme.tokens.content.secondary}; + font-variant-numeric: tabular-nums; +`; + // Empty spacers to match StreamGroup column widths and keep alignment const PrioritySpacer = styled('div')` width: 64px; diff --git a/static/app/constants/index.tsx b/static/app/constants/index.tsx index 583236a1bd4b99..8630a1b79ae4b5 100644 --- a/static/app/constants/index.tsx +++ b/static/app/constants/index.tsx @@ -653,7 +653,7 @@ export const DATA_CATEGORY_INFO = { titleName: t('Metrics (Bytes)'), productName: t('Metrics'), uid: 37, - isBilledCategory: false, + isBilledCategory: true, statsInfo: { ...DEFAULT_STATS_INFO, showExternalStats: true, diff --git a/static/app/types/system.tsx b/static/app/types/system.tsx index 54cf3721c69fe0..e9a4342c49c467 100644 --- a/static/app/types/system.tsx +++ b/static/app/types/system.tsx @@ -225,6 +225,7 @@ export interface Config { latest: string; upgradeAvailable: boolean; }; + intercomAppId?: string; partnershipAgreementPrompt?: { agreements: ParntershipAgreementType[]; partnerDisplayName: string; diff --git a/static/app/utils/intercom.tsx b/static/app/utils/intercom.tsx new file mode 100644 index 00000000000000..b2147227a2c80f --- /dev/null +++ b/static/app/utils/intercom.tsx @@ -0,0 +1,90 @@ +/** + * Intercom Messenger utilities. + * + * Uses the official @intercom/messenger-js-sdk for React integration. + * Intercom is lazily initialized on first "Contact Support" click. + */ + +import {Client} from 'sentry/api'; +import {ConfigStore} from 'sentry/stores/configStore'; + +interface IntercomUserData { + createdAt: number; + email: string; + name: string; + organizationId: string; + organizationName: string; + userId: string; +} + +interface IntercomJwtResponse { + jwt: string; + userData: IntercomUserData; +} + +let hasBooted = false; +let bootPromise: Promise | null = null; + +/** + * Initialize Intercom with identity verification. + * Only fetches JWT and boots on first call. + */ +async function initIntercom(orgSlug: string): Promise { + if (hasBooted) { + return; + } + + // Prevent concurrent initialization + if (bootPromise) { + return bootPromise; + } + + bootPromise = (async () => { + try { + const intercomAppId = ConfigStore.get('intercomAppId'); + if (!intercomAppId) { + throw new Error('Intercom app ID not configured'); + } + + // Fetch JWT for identity verification + const api = new Client(); + const jwtData: IntercomJwtResponse = await api.requestPromise( + `/organizations/${orgSlug}/intercom-jwt/` + ); + + // Boot Intercom with user data + const {default: Intercom} = await import('@intercom/messenger-js-sdk'); + Intercom({ + app_id: intercomAppId, + user_id: jwtData.userData.userId, + user_hash: jwtData.jwt, + email: jwtData.userData.email, + name: jwtData.userData.name, + created_at: jwtData.userData.createdAt, + company: { + company_id: jwtData.userData.organizationId, + name: jwtData.userData.organizationName, + }, + hide_default_launcher: true, + }); + + hasBooted = true; + } catch (error) { + // Reset so user can retry on next click + bootPromise = null; + throw error; + } + })(); + + return bootPromise; +} + +/** + * Show the Intercom Messenger. + * Lazily initializes Intercom on first call. + */ +export async function showIntercom(orgSlug: string): Promise { + await initIntercom(orgSlug); + const {show} = await import('@intercom/messenger-js-sdk'); + show(); +} diff --git a/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx b/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx index 34dfba24dcc860..954b5d0161c86a 100644 --- a/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx +++ b/static/app/utils/replays/hooks/useActiveReplayTab.spec.tsx @@ -1,7 +1,7 @@ import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture'; import {OrganizationFixture} from 'sentry-fixture/organization'; -import {act, renderHookWithProviders} from 'sentry-test/reactTestingLibrary'; +import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; import {setWindowLocation} from 'sentry-test/utils'; import {TabKey, useActiveReplayTab} from 'sentry/utils/replays/hooks/useActiveReplayTab'; @@ -40,9 +40,12 @@ describe('useActiveReplayTab', () => { expect(result.current.getActiveTab()).toBe(TabKey.AI); }); - it('should set the default tab if the name is invalid', () => { + it('should set the default tab if the name is invalid', async () => { const {result, router} = renderHookWithProviders(useActiveReplayTab, { initialProps: {}, + initialRouterConfig: { + location: {pathname: '/mock-pathname/', query: {query: 'click.tag:button'}}, + }, organization: OrganizationFixture({ features: ['gen-ai-features', 'replay-ai-summaries'], }), @@ -50,7 +53,12 @@ describe('useActiveReplayTab', () => { expect(result.current.getActiveTab()).toBe(TabKey.AI); act(() => result.current.setActiveTab('foo bar')); - expect(router.location.query).toEqual({query: 'click.tag:button', t_main: 'ai'}); + await waitFor(() => { + expect(router.location.query).toEqual({ + query: 'click.tag:button', + t_main: 'ai', + }); + }); }); it('should use AI as default for video replays when replay-ai-summaries-mobile is enabled', () => { @@ -103,34 +111,43 @@ describe('useActiveReplayTab', () => { expect(result.current.getActiveTab()).toBe(TabKey.BREADCRUMBS); }); - it('should set the default tab if the name is invalid', () => { + it('should set the default tab if the name is invalid', async () => { const {result, router} = renderHookWithProviders(useActiveReplayTab, { initialProps: {}, + initialRouterConfig: { + location: {pathname: '/mock-pathname/', query: {query: 'click.tag:button'}}, + }, organization: OrganizationFixture({features: []}), }); expect(result.current.getActiveTab()).toBe(TabKey.BREADCRUMBS); act(() => result.current.setActiveTab('foo bar')); - expect(router.location.query).toEqual({ - query: 'click.tag:button', - t_main: 'breadcrumbs', + await waitFor(() => { + expect(router.location.query).toEqual({ + query: 'click.tag:button', + t_main: 'breadcrumbs', + }); }); }); }); }); - it('should allow case-insensitive tab names', () => { + it('should allow case-insensitive tab names', async () => { const {result, router} = renderHookWithProviders(useActiveReplayTab, { initialProps: {}, + initialRouterConfig: { + location: {pathname: '/mock-pathname/', query: {query: 'click.tag:button'}}, + }, organization: OrganizationFixture({features: []}), }); expect(result.current.getActiveTab()).toBe(TabKey.BREADCRUMBS); act(() => result.current.setActiveTab('nEtWoRk')); - - expect(router.location.query).toEqual({ - query: 'click.tag:button', - t_main: 'network', + await waitFor(() => { + expect(router.location.query).toEqual({ + query: 'click.tag:button', + t_main: 'network', + }); }); }); }); diff --git a/static/app/utils/replays/hooks/useActiveReplayTab.tsx b/static/app/utils/replays/hooks/useActiveReplayTab.tsx index fe288e8fa36f8d..0379b28ebc12d6 100644 --- a/static/app/utils/replays/hooks/useActiveReplayTab.tsx +++ b/static/app/utils/replays/hooks/useActiveReplayTab.tsx @@ -1,8 +1,8 @@ import {useCallback} from 'react'; +import {createParser, parseAsStringLiteral, useQueryState} from 'nuqs'; import {useOrganizationSeerSetup} from 'sentry/components/events/autofix/useOrganizationSeerSetup'; import {defined} from 'sentry/utils'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {useOrganization} from 'sentry/utils/useOrganization'; export enum TabKey { @@ -10,34 +10,38 @@ export enum TabKey { BREADCRUMBS = 'breadcrumbs', CONSOLE = 'console', ERRORS = 'errors', + LOGS = 'logs', MEMORY = 'memory', NETWORK = 'network', + PLAYLIST = 'playlist', TAGS = 'tags', TRACE = 'trace', - LOGS = 'logs', - PLAYLIST = 'playlist', } function isReplayTab({tab, isVideoReplay}: {isVideoReplay: boolean; tab: string}) { - const supportedVideoTabs = [ - TabKey.TAGS, - TabKey.ERRORS, - TabKey.BREADCRUMBS, - TabKey.NETWORK, - TabKey.CONSOLE, - TabKey.TRACE, - TabKey.LOGS, - TabKey.AI, - TabKey.PLAYLIST, - ]; - if (isVideoReplay) { + const supportedVideoTabs = [ + TabKey.AI, + TabKey.BREADCRUMBS, + TabKey.CONSOLE, + TabKey.ERRORS, + TabKey.LOGS, + TabKey.NETWORK, + TabKey.PLAYLIST, + TabKey.TAGS, + TabKey.TRACE, + ]; return supportedVideoTabs.includes(tab as TabKey); } return Object.values(TabKey).includes(tab); } +const tabKeyParser = createParser({ + parse: value => parseAsStringLiteral(Object.values(TabKey)).parse(value.toLowerCase()), + serialize: value => value, +}); + export function useActiveReplayTab({isVideoReplay = false}: {isVideoReplay?: boolean}) { const organization = useOrganization(); const {areAiFeaturesAllowed} = useOrganizationSeerSetup(); @@ -52,24 +56,23 @@ export function useActiveReplayTab({isVideoReplay = false}: {isVideoReplay?: boo ? TabKey.AI : TabKey.BREADCRUMBS; - const {getParamValue, setParamValue} = useUrlParams('t_main', defaultTab); - - const paramValue = getParamValue()?.toLowerCase() ?? ''; + const [tabParam, setTabParam] = useQueryState( + 't_main', + tabKeyParser.withDefault(defaultTab).withOptions({clearOnDefault: false}) + ); return { getActiveTab: useCallback( - () => (isReplayTab({tab: paramValue, isVideoReplay}) ? paramValue : defaultTab), - [paramValue, defaultTab, isVideoReplay] + () => + tabParam && isReplayTab({tab: tabParam, isVideoReplay}) ? tabParam : defaultTab, + [tabParam, defaultTab, isVideoReplay] ), setActiveTab: useCallback( (value: string) => { - setParamValue( - isReplayTab({tab: value.toLowerCase(), isVideoReplay}) - ? value.toLowerCase() - : defaultTab - ); + const lower = value.toLowerCase() as TabKey; + setTabParam(isReplayTab({tab: lower, isVideoReplay}) ? lower : defaultTab); }, - [setParamValue, defaultTab, isVideoReplay] + [setTabParam, defaultTab, isVideoReplay] ), }; } diff --git a/static/app/utils/supergroup/aggregateSupergroupStats.ts b/static/app/utils/supergroup/aggregateSupergroupStats.ts deleted file mode 100644 index 09e7013875168f..00000000000000 --- a/static/app/utils/supergroup/aggregateSupergroupStats.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type {TimeseriesValue} from 'sentry/types/core'; -import type {Group} from 'sentry/types/group'; - -export interface AggregatedSupergroupStats { - eventCount: number; - firstSeen: string | null; - lastSeen: string | null; - mergedStats: TimeseriesValue[]; - userCount: number; -} - -/** - * Aggregate stats from member groups for display in a supergroup row. - * Sums event/user counts, takes min firstSeen and max lastSeen, - * and point-wise sums the trend data. - */ -export function aggregateSupergroupStats( - groups: Group[], - statsPeriod: string -): AggregatedSupergroupStats | null { - if (groups.length === 0) { - return null; - } - - let eventCount = 0; - let userCount = 0; - let firstSeen: string | null = null; - let lastSeen: string | null = null; - let mergedStats: TimeseriesValue[] = []; - - for (const group of groups) { - eventCount += parseInt(group.count, 10) || 0; - userCount += group.userCount || 0; - - const gFirstSeen = group.lifetime?.firstSeen ?? group.firstSeen; - if (gFirstSeen && (!firstSeen || gFirstSeen < firstSeen)) { - firstSeen = gFirstSeen; - } - - const gLastSeen = group.lifetime?.lastSeen ?? group.lastSeen; - if (gLastSeen && (!lastSeen || gLastSeen > lastSeen)) { - lastSeen = gLastSeen; - } - - const stats = group.stats?.[statsPeriod]; - if (stats) { - if (mergedStats.length === 0) { - mergedStats = stats.map(([ts, val]) => [ts, val] as TimeseriesValue); - } else { - for (let i = 0; i < Math.min(mergedStats.length, stats.length); i++) { - mergedStats[i] = [mergedStats[i]![0], mergedStats[i]![1] + stats[i]![1]]; - } - } - } - } - - return {eventCount, userCount, firstSeen, lastSeen, mergedStats}; -} diff --git a/static/app/utils/testableTransition.tsx b/static/app/utils/testableTransition.tsx deleted file mode 100644 index dc35b1415ba3df..00000000000000 --- a/static/app/utils/testableTransition.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type {Transition} from 'framer-motion'; - -import {IS_ACCEPTANCE_TEST, NODE_ENV} from 'sentry/constants'; - -/** - * Use with a framer-motion transition to disable the animation in testing - * environments. - * - * If your animation has no transition you can simply specify - * - * ```tsx - * Component.defaultProps = { - * transition: testableTransition(), - * } - * ``` - * - * This function simply disables the animation `type`. - */ -export const testableTransition = - !IS_ACCEPTANCE_TEST && NODE_ENV !== 'test' - ? (t?: Transition) => t - : function (): Transition { - return { - delay: 0, - staggerChildren: 0, - type: false, - }; - }; diff --git a/static/app/utils/url/useUrlParams.spec.tsx b/static/app/utils/url/useUrlParams.spec.tsx deleted file mode 100644 index 8ed30b402fa5c7..00000000000000 --- a/static/app/utils/url/useUrlParams.spec.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import * as qs from 'query-string'; - -import {renderHook} from 'sentry-test/reactTestingLibrary'; -import {setWindowLocation} from 'sentry-test/utils'; - -import {browserHistory} from 'sentry/utils/browserHistory'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; - -describe('useUrlParams', () => { - beforeEach(() => { - setWindowLocation( - `http://localhost/?${qs.stringify({ - page: '3', - limit: '50', - array: ['first', 'second'], - })}` - ); - }); - - it('should read query values from the url', () => { - const {result} = renderHook(useUrlParams); - - expect(result.current.getParamValue('page')).toBe('3'); - expect(result.current.getParamValue('limit')).toBe('50'); - expect(result.current.getParamValue('array')).toBe('first'); - expect(result.current.getParamValue('foo')).toBeUndefined(); - }); - - it('should read a specific query value if the defaultKey is passed along', () => { - const {result} = renderHook((args: [string]) => useUrlParams(args[0]), { - initialProps: ['page'], - }); - - expect(result.current.getParamValue()).toBe('3'); - }); - - it('should read the default value for the defaultKey', () => { - const {result} = renderHook( - (args: [string, string]) => useUrlParams(args[0], args[1]), - { - initialProps: ['foo', 'bar'], - } - ); // Prefer TS function overloading, not initialProps - - expect(result.current.getParamValue()).toBe('bar'); - }); - - it('should update browser history with new values', () => { - const {result} = renderHook(useUrlParams); - - result.current.setParamValue('page', '4'); - - expect(browserHistory.push).toHaveBeenCalledWith({ - pathname: '/', - query: { - array: ['first', 'second'], - page: '4', - limit: '50', - }, - }); - }); - - it('should update browser history with new values for the defaultKey', () => { - const {result} = renderHook((args: [string]) => useUrlParams(args[0]), { - initialProps: ['page'], - }); - - result.current.setParamValue('4'); - - expect(browserHistory.push).toHaveBeenCalledWith({ - pathname: '/', - query: { - array: ['first', 'second'], - page: '4', - limit: '50', - }, - }); - }); - - it('uses the same function reference after each render', () => { - const {result, rerender} = renderHook(useUrlParams); - - const firstResult = result.current; - rerender(); - const secondResult = result.current; - - expect(firstResult.getParamValue).toBe(secondResult.getParamValue); - expect(firstResult.setParamValue).toBe(secondResult.setParamValue); - }); -}); diff --git a/static/app/utils/url/useUrlParams.tsx b/static/app/utils/url/useUrlParams.tsx deleted file mode 100644 index e5e5711d82a03c..00000000000000 --- a/static/app/utils/url/useUrlParams.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import {useCallback} from 'react'; -import * as qs from 'query-string'; - -import {browserHistory} from 'sentry/utils/browserHistory'; - -// TODO(epurkhiser): Once we're on react-router 6 we should replace this with -// their useSearchParams hook - -export function useUrlParams( - defaultKey: string, - defaultValue: string -): { - getParamValue: () => string; - setParamValue: (value: string) => void; -}; -export function useUrlParams(defaultKey: string): { - getParamValue: () => string | undefined; - setParamValue: (value: string) => void; -}; -export function useUrlParams(): { - getParamValue: (key: string) => string | undefined; - setParamValue: (key: string, value: string) => void; -}; -export function useUrlParams(defaultKey?: string, defaultValue?: string) { - const getParamValue = useCallback( - (key: string) => { - const currentQuery = qs.parse(window.location.search); - - // location.query.key can return string[] but we expect a singular value - // from this function, so we return the first string (this is picked - // arbitrarily) if it's string[] - return Array.isArray(currentQuery[key]) - ? (currentQuery[key]?.at(0) ?? defaultValue) - : (currentQuery[key] ?? defaultValue); - }, - [defaultValue] - ); - - const setParamValue = useCallback((key: string, value: string) => { - const currentQuery = qs.parse(window.location.search); - const query = {...currentQuery, [key]: value}; - browserHistory.push({pathname: location.pathname, query}); - }, []); - - const getWithDefault = useCallback( - () => getParamValue(defaultKey || ''), - [getParamValue, defaultKey] - ); - const setWithDefault = useCallback( - (value: string) => setParamValue(defaultKey || '', value), - [setParamValue, defaultKey] - ); - - if (defaultKey !== undefined) { - return { - getParamValue: getWithDefault, - setParamValue: setWithDefault, - }; - } - - return { - getParamValue, - setParamValue, - }; -} diff --git a/static/app/utils/useParams.tsx b/static/app/utils/useParams.tsx index 39b13f28612177..af1d8b9fb5fb48 100644 --- a/static/app/utils/useParams.tsx +++ b/static/app/utils/useParams.tsx @@ -20,6 +20,7 @@ type ParamKeys = | 'baseArtifactId' | 'beaconId' | 'broadcastId' + | 'clientID' | 'codeId' | 'dashboardId' | 'dataExportId' diff --git a/static/app/views/dashboards/widgetBuilder/hooks/useDashboardWidgetSource.tsx b/static/app/views/dashboards/widgetBuilder/hooks/useDashboardWidgetSource.tsx index dcbe784fbbc700..b547eb386211e5 100644 --- a/static/app/views/dashboards/widgetBuilder/hooks/useDashboardWidgetSource.tsx +++ b/static/app/views/dashboards/widgetBuilder/hooks/useDashboardWidgetSource.tsx @@ -1,10 +1,10 @@ +import {parseAsString, useQueryState} from 'nuqs'; + import {defined} from 'sentry/utils'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {DashboardWidgetSource} from 'sentry/views/dashboards/types'; export function useDashboardWidgetSource(): DashboardWidgetSource | '' { - const {getParamValue} = useUrlParams('source'); - const source = getParamValue(); + const [source] = useQueryState('source', parseAsString); const validSources = Object.values( DashboardWidgetSource diff --git a/static/app/views/detectors/components/forms/common/assignSection.tsx b/static/app/views/detectors/components/forms/common/assignSection.tsx deleted file mode 100644 index ed5bae04a85448..00000000000000 --- a/static/app/views/detectors/components/forms/common/assignSection.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import {useMemo} from 'react'; -import styled from '@emotion/styled'; - -import {SentryMemberTeamSelectorField} from 'sentry/components/forms/fields/sentryMemberTeamSelectorField'; -import {useFormField} from 'sentry/components/workflowEngine/form/useFormField'; -import {Container} from 'sentry/components/workflowEngine/ui/container'; -import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; -import {t} from 'sentry/locale'; -import {useProjects} from 'sentry/utils/useProjects'; - -function AssigneeField({projectId}: {projectId?: string}) { - const {projects} = useProjects(); - const memberOfProjectSlugs = useMemo(() => { - const project = projects.find(p => p.id === projectId); - return project ? [project.slug] : undefined; - }, [projects, projectId]); - - return ( - - ); -} - -export function AssignSection({step}: {step?: number}) { - const projectId = useFormField('projectId'); - - return ( - - - - - - ); -} - -const StyledMemberTeamSelectorField = styled(SentryMemberTeamSelectorField)` - padding: 0; -`; diff --git a/static/app/views/detectors/components/forms/common/describeSection.tsx b/static/app/views/detectors/components/forms/common/describeSection.tsx deleted file mode 100644 index d972b2b8a401b3..00000000000000 --- a/static/app/views/detectors/components/forms/common/describeSection.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import styled from '@emotion/styled'; - -import {TextareaField} from 'sentry/components/forms/fields/textareaField'; -import {Container} from 'sentry/components/workflowEngine/ui/container'; -import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; -import {t} from 'sentry/locale'; - -export function DescribeSection({step}: {step?: number}) { - return ( - - - - - - ); -} - -// Min height helps prevent resize after placeholder is replaced with user input -const MinHeightTextarea = styled(TextareaField)` - padding: 0; - textarea { - min-height: 140px; - } -`; diff --git a/static/app/views/detectors/components/forms/common/issueOwnershipSection.tsx b/static/app/views/detectors/components/forms/common/issueOwnershipSection.tsx new file mode 100644 index 00000000000000..c074b07f61973e --- /dev/null +++ b/static/app/views/detectors/components/forms/common/issueOwnershipSection.tsx @@ -0,0 +1,70 @@ +import {useMemo} from 'react'; +import styled from '@emotion/styled'; + +import {Stack} from '@sentry/scraps/layout'; + +import {SentryMemberTeamSelectorField} from 'sentry/components/forms/fields/sentryMemberTeamSelectorField'; +import {FormField} from 'sentry/components/forms/formField'; +import {MarkdownTextArea} from 'sentry/components/markdownTextArea'; +import {useFormField} from 'sentry/components/workflowEngine/form/useFormField'; +import {Container} from 'sentry/components/workflowEngine/ui/container'; +import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; +import {t} from 'sentry/locale'; +import {useProjects} from 'sentry/utils/useProjects'; + +export function IssueOwnershipSection({step}: {step?: number}) { + const projectId = useFormField('projectId'); + const {projects} = useProjects(); + const memberOfProjectSlugs = useMemo(() => { + const project = projects.find(p => p.id === projectId); + return project ? [project.slug] : undefined; + }, [projects, projectId]); + + return ( + + + + + + {fieldProps => ( + + )} + + + + + ); +} + +const OwnershipField = styled(SentryMemberTeamSelectorField)` + padding: ${p => p.theme.space.lg} 0; +`; + +const DescriptionField = styled(FormField)` + padding: ${p => p.theme.space.lg} 0; +`; diff --git a/static/app/views/detectors/components/forms/cron/cronIssuePreview.tsx b/static/app/views/detectors/components/forms/cron/cronIssuePreview.tsx new file mode 100644 index 00000000000000..6269b074e045e0 --- /dev/null +++ b/static/app/views/detectors/components/forms/cron/cronIssuePreview.tsx @@ -0,0 +1,37 @@ +import {t} from 'sentry/locale'; +import {DetectorIssuePreview} from 'sentry/views/detectors/components/forms/common/detectorIssuePreview'; +import {IssuePreviewSection} from 'sentry/views/detectors/components/forms/common/issuePreviewSection'; +import {ownerToActor} from 'sentry/views/detectors/components/forms/common/ownerToActor'; +import {useDetectorFormContext} from 'sentry/views/detectors/components/forms/context'; +import {useCronDetectorFormField} from 'sentry/views/detectors/components/forms/cron/fields'; + +const FALLBACK_ISSUE_TITLE = t('Cron failure: …'); +const SUBTITLE = t('Your monitor is failing: A missed check-in was detected'); + +function useCronIssueTitle() { + const name = useCronDetectorFormField('name'); + + if (!name) { + return FALLBACK_ISSUE_TITLE; + } + + return t('Cron failure: %s', name); +} + +export function CronIssuePreview({step}: {step?: number}) { + const owner = useCronDetectorFormField('owner'); + const issueTitle = useCronIssueTitle(); + const assignee = ownerToActor(owner); + const {project} = useDetectorFormContext(); + + return ( + + + + ); +} diff --git a/static/app/views/detectors/components/forms/cron/detect.tsx b/static/app/views/detectors/components/forms/cron/detect.tsx index 7e312dd6f1ea9f..0fcbc35c6ea2e9 100644 --- a/static/app/views/detectors/components/forms/cron/detect.tsx +++ b/static/app/views/detectors/components/forms/cron/detect.tsx @@ -201,7 +201,7 @@ function Thresholds() { export function CronDetectorFormDetectSection({step}: {step?: number}) { return ( - +
{t('Set your schedule')} diff --git a/static/app/views/detectors/components/forms/cron/index.spec.tsx b/static/app/views/detectors/components/forms/cron/index.spec.tsx index 3e6f53dd7767f5..8ccf62848d5bf2 100644 --- a/static/app/views/detectors/components/forms/cron/index.spec.tsx +++ b/static/app/views/detectors/components/forms/cron/index.spec.tsx @@ -1,7 +1,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {ProjectFixture} from 'sentry-fixture/project'; -import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import {OrganizationStore} from 'sentry/stores/organizationStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; @@ -58,8 +58,7 @@ describe('NewCronDetectorForm', () => { // Form sections should be visible expect(await screen.findByText(/Detect/)).toBeInTheDocument(); - expect(screen.getByText(/Assign/)).toBeInTheDocument(); - expect(screen.getByText(/Description/)).toBeInTheDocument(); + expect(screen.getByText(/Issue Ownership/)).toBeInTheDocument(); // Create Monitor button should be present and enabled const createButton = screen.getByRole('button', {name: 'Create Monitor'}); @@ -80,8 +79,7 @@ describe('NewCronDetectorForm', () => { // Form sections should be hidden expect(screen.queryByText(/Detect/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Assign/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Description/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Issue Ownership/)).not.toBeInTheDocument(); // Create Monitor button should be present but disabled const createButton = screen.getByRole('button', {name: 'Create Monitor'}); @@ -89,6 +87,27 @@ describe('NewCronDetectorForm', () => { expect(createButton).toBeDisabled(); }); + it('renders issue preview and updates title when name changes', async () => { + renderForm(); + + // Issue preview section should render with fallback title + expect(await screen.findByTestId('issue-preview-section')).toBeInTheDocument(); + expect(screen.getByText('Cron failure: …')).toBeInTheDocument(); + expect( + screen.getByText('Your monitor is failing: A missed check-in was detected') + ).toBeInTheDocument(); + + // Edit the monitor name + const title = screen.getByText('New Monitor'); + await userEvent.click(title); + const nameInput = screen.getByRole('textbox', {name: 'Monitor Name'}); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, 'My Cron Job{Enter}'); + + // Issue preview updates with the new name + expect(await screen.findByText('Cron failure: My Cron Job')).toBeInTheDocument(); + }); + it('shows form sections and enabled button when guide is set to "manual"', async () => { renderForm({ location: { @@ -99,8 +118,7 @@ describe('NewCronDetectorForm', () => { // Form sections should be visible even with platform set, because guide is "manual" expect(await screen.findByText(/Detect/)).toBeInTheDocument(); - expect(screen.getByText(/Assign/)).toBeInTheDocument(); - expect(screen.getByText(/Description/)).toBeInTheDocument(); + expect(screen.getByText(/Issue Ownership/)).toBeInTheDocument(); // Create Monitor button should be present and enabled const createButton = screen.getByRole('button', {name: 'Create Monitor'}); diff --git a/static/app/views/detectors/components/forms/cron/index.tsx b/static/app/views/detectors/components/forms/cron/index.tsx index 2d53658f1eec1a..e3b7eca7dc386d 100644 --- a/static/app/views/detectors/components/forms/cron/index.tsx +++ b/static/app/views/detectors/components/forms/cron/index.tsx @@ -7,8 +7,7 @@ import {Stack} from '@sentry/scraps/layout'; import {t} from 'sentry/locale'; import type {CronDetector} from 'sentry/types/workflowEngine/detectors'; import {AutomateSection} from 'sentry/views/detectors/components/forms/automateSection'; -import {AssignSection} from 'sentry/views/detectors/components/forms/common/assignSection'; -import {DescribeSection} from 'sentry/views/detectors/components/forms/common/describeSection'; +import {IssueOwnershipSection} from 'sentry/views/detectors/components/forms/common/issueOwnershipSection'; import {ProjectSection} from 'sentry/views/detectors/components/forms/common/projectSection'; import {CronDetectorFormDetectSection} from 'sentry/views/detectors/components/forms/cron/detect'; import { @@ -22,6 +21,7 @@ import {EditDetectorLayout} from 'sentry/views/detectors/components/forms/editDe import {NewDetectorLayout} from 'sentry/views/detectors/components/forms/newDetectorLayout'; import {useCronsUpsertGuideState} from 'sentry/views/insights/crons/components/useCronsUpsertGuideState'; +import {CronIssuePreview} from './cronIssuePreview'; import {PreviewSection} from './previewSection'; function useIsShowingPlatformGuide() { @@ -33,8 +33,8 @@ const FORM_SECTIONS = [ ProjectSection, CronDetectorFormDetectSection, CronDetectorFormResolveSection, - AssignSection, - DescribeSection, + IssueOwnershipSection, + CronIssuePreview, AutomateSection, ]; diff --git a/static/app/views/detectors/components/forms/cron/resolve.tsx b/static/app/views/detectors/components/forms/cron/resolve.tsx index e62533dcdd1dbe..dc0acf593d61fa 100644 --- a/static/app/views/detectors/components/forms/cron/resolve.tsx +++ b/static/app/views/detectors/components/forms/cron/resolve.tsx @@ -10,7 +10,7 @@ import {CRON_DEFAULT_RECOVERY_THRESHOLD} from 'sentry/views/detectors/components export function CronDetectorFormResolveSection({step}: {step?: number}) { return ( - + ('projectId'); - const {projects} = useProjects(); - const memberOfProjectSlugs = useMemo(() => { - const project = projects.find(p => p.id === projectId); - return project ? [project.slug] : undefined; - }, [projects, projectId]); - - return ( - - - - - - - - - ); -} - function TransactionsDatasetWarningListener() { const dataset = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.dataset); if (dataset !== DetectorDataset.TRANSACTIONS) { @@ -852,15 +806,3 @@ const RequiredAsterisk = styled('span')` color: ${p => p.theme.tokens.content.danger}; margin-left: ${p => p.theme.space['2xs']}; `; - -const OwnershipField = styled(SentryMemberTeamSelectorField)` - padding: ${p => p.theme.space.lg} 0; -`; - -// Min height helps prevent resize after placeholder is replaced with user input -const MinHeightTextarea = styled(TextareaField)` - padding: ${p => p.theme.space.lg} 0; - textarea { - min-height: 140px; - } -`; diff --git a/static/app/views/detectors/components/forms/mobileBuild/index.tsx b/static/app/views/detectors/components/forms/mobileBuild/index.tsx index cac5c11ea74527..1e0c8bd23f5ead 100644 --- a/static/app/views/detectors/components/forms/mobileBuild/index.tsx +++ b/static/app/views/detectors/components/forms/mobileBuild/index.tsx @@ -10,8 +10,7 @@ import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; import {t} from 'sentry/locale'; import type {PreprodDetector} from 'sentry/types/workflowEngine/detectors'; import {AutomateSection} from 'sentry/views/detectors/components/forms/automateSection'; -import {AssignSection} from 'sentry/views/detectors/components/forms/common/assignSection'; -import {DescribeSection} from 'sentry/views/detectors/components/forms/common/describeSection'; +import {IssueOwnershipSection} from 'sentry/views/detectors/components/forms/common/issueOwnershipSection'; import {ProjectSection} from 'sentry/views/detectors/components/forms/common/projectSection'; import {EditDetectorLayout} from 'sentry/views/detectors/components/forms/editDetectorLayout'; import {MobileBuildDetectSection} from 'sentry/views/detectors/components/forms/mobileBuild/detectSection'; @@ -63,10 +62,9 @@ function MobileBuildDetectorForm() { /> - - - - + + + ); } diff --git a/static/app/views/detectors/components/forms/uptime/detect/index.tsx b/static/app/views/detectors/components/forms/uptime/detect/index.tsx index 29db6e86c4ec5e..9c5ca1962bdd6f 100644 --- a/static/app/views/detectors/components/forms/uptime/detect/index.tsx +++ b/static/app/views/detectors/components/forms/uptime/detect/index.tsx @@ -62,7 +62,7 @@ function ConnectedHttpSnippet() { export function UptimeDetectorFormDetectSection({step}: {step?: number}) { return ( - + ({ diff --git a/static/app/views/detectors/components/forms/uptime/formatUptimeUrl.spec.ts b/static/app/views/detectors/components/forms/uptime/formatUptimeUrl.spec.ts new file mode 100644 index 00000000000000..216839011e4805 --- /dev/null +++ b/static/app/views/detectors/components/forms/uptime/formatUptimeUrl.spec.ts @@ -0,0 +1,17 @@ +import {formatUptimeUrl} from 'sentry/views/detectors/components/forms/uptime/formatUptimeUrl'; + +describe('formatUptimeUrl', () => { + it('returns the host when the URL has no path', () => { + expect(formatUptimeUrl('https://example.com')).toBe('example.com'); + }); + + it('includes the path and strips a trailing slash', () => { + expect(formatUptimeUrl('https://example.com/health/check/')).toBe( + 'example.com/health/check' + ); + }); + + it('returns null for invalid URLs', () => { + expect(formatUptimeUrl('not-a-url')).toBeNull(); + }); +}); diff --git a/static/app/views/detectors/components/forms/uptime/formatUptimeUrl.ts b/static/app/views/detectors/components/forms/uptime/formatUptimeUrl.ts new file mode 100644 index 00000000000000..a46287d0d2d339 --- /dev/null +++ b/static/app/views/detectors/components/forms/uptime/formatUptimeUrl.ts @@ -0,0 +1,15 @@ +/** + * Takes a full URL used by the uptime detector and formats it nicely for display purposes + * + * https://example.com/health/check/ -> example.com/health/check + */ +export function formatUptimeUrl(url: string): string | null { + const parsedUrl = URL.parse(url); + if (!parsedUrl?.hostname) { + return null; + } + + const path = parsedUrl.pathname === '/' ? '' : parsedUrl.pathname; + + return `${parsedUrl.hostname}${path}`.replace(/\/$/, ''); +} diff --git a/static/app/views/detectors/components/forms/uptime/index.tsx b/static/app/views/detectors/components/forms/uptime/index.tsx index 8cc3a603631564..f86f84e33cbc96 100644 --- a/static/app/views/detectors/components/forms/uptime/index.tsx +++ b/static/app/views/detectors/components/forms/uptime/index.tsx @@ -11,8 +11,7 @@ import { } from 'sentry/views/alerts/rules/uptime/previewCheckContext'; import {useUptimeAssertionFeatures} from 'sentry/views/alerts/rules/uptime/useUptimeAssertionFeatures'; import {AutomateSection} from 'sentry/views/detectors/components/forms/automateSection'; -import {AssignSection} from 'sentry/views/detectors/components/forms/common/assignSection'; -import {DescribeSection} from 'sentry/views/detectors/components/forms/common/describeSection'; +import {IssueOwnershipSection} from 'sentry/views/detectors/components/forms/common/issueOwnershipSection'; import { ProjectEnvironmentSection, type EnvironmentConfig, @@ -27,9 +26,11 @@ import { uptimeFormDataToEndpointPayload, uptimeSavedDetectorToFormData, } from 'sentry/views/detectors/components/forms/uptime/fields'; +import {formatUptimeUrl} from 'sentry/views/detectors/components/forms/uptime/formatUptimeUrl'; import {PreviewSection} from 'sentry/views/detectors/components/forms/uptime/previewSection'; import {UptimeRegionWarning} from 'sentry/views/detectors/components/forms/uptime/regionWarning'; import {UptimeDetectorResolveSection} from 'sentry/views/detectors/components/forms/uptime/resolve'; +import {UptimeIssuePreview} from 'sentry/views/detectors/components/forms/uptime/uptimeIssuePreview'; import {UptimeDetectorVerificationSection} from 'sentry/views/detectors/components/forms/uptime/verification'; const ENVIRONMENT_CONFIG: EnvironmentConfig = { @@ -49,14 +50,11 @@ function UptimeDetectorForm() { return null; } - const parsedUrl = URL.parse(url); - if (!parsedUrl) { + const urlName = formatUptimeUrl(url); + if (!urlName) { return null; } - const path = parsedUrl.pathname === '/' ? '' : parsedUrl.pathname; - const urlName = `${parsedUrl.hostname}${path}`.replace(/\/$/, ''); - return t('Uptime check for %s', urlName); }); @@ -68,8 +66,8 @@ function UptimeDetectorForm() { {hasRuntimeAssertions && } - - + + ); diff --git a/static/app/views/detectors/components/forms/uptime/resolve.tsx b/static/app/views/detectors/components/forms/uptime/resolve.tsx index 1cddc3cb991d57..a17f2827802901 100644 --- a/static/app/views/detectors/components/forms/uptime/resolve.tsx +++ b/static/app/views/detectors/components/forms/uptime/resolve.tsx @@ -11,7 +11,7 @@ import {UPTIME_DEFAULT_RECOVERY_THRESHOLD} from 'sentry/views/detectors/componen export function UptimeDetectorResolveSection({step}: {step?: number}) { return ( - +
+ + + ); +} diff --git a/static/app/views/detectors/new-setting.spec.tsx b/static/app/views/detectors/new-setting.spec.tsx index 434f16d58d952c..adf9ab44d7a961 100644 --- a/static/app/views/detectors/new-setting.spec.tsx +++ b/static/app/views/detectors/new-setting.spec.tsx @@ -7,7 +7,13 @@ import { import {OrganizationFixture} from 'sentry-fixture/organization'; import {ProjectFixture} from 'sentry-fixture/project'; -import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; +import { + render, + screen, + userEvent, + waitFor, + within, +} from 'sentry-test/reactTestingLibrary'; import {selectEvent} from 'sentry-test/selectEvent'; import {OrganizationStore} from 'sentry/stores/organizationStore'; @@ -103,6 +109,27 @@ describe('DetectorEdit', () => { }); }); + it('selects the first project when an invalid project is provided in the URL', async () => { + render(, { + organization, + initialRouterConfig: { + ...initialRouterConfig, + location: { + ...initialRouterConfig.location, + query: {detectorType: 'metric_issue', project: 'not-a-project-id'}, + }, + }, + }); + + await screen.findByText('New Monitor'); + + // Verify the project dropdown has the first project selected + const projectSection = screen + .getByText(/Choose the Project and Environment/) + .closest('section')!; + expect(within(projectSection).getByText(project.slug)).toBeInTheDocument(); + }); + describe('Metric Detector', () => { const metricRouterConfig = { ...initialRouterConfig, @@ -930,6 +957,11 @@ describe('DetectorEdit', () => { await userEvent.click(bodyInput); await userEvent.paste('{"test": "data"}'); + // Issue preview reflects the URL + expect( + screen.getByText('Downtime detected for uptime.example.com') + ).toBeInTheDocument(); + await selectEvent.openMenu(screen.getByLabelText('Select Environment')); expect( screen.queryByRole('menuitemradio', {name: 'All Environments'}) diff --git a/static/app/views/detectors/new-settings.tsx b/static/app/views/detectors/new-settings.tsx index 395e84f6cba5c8..e05ad2dc1df0d0 100644 --- a/static/app/views/detectors/new-settings.tsx +++ b/static/app/views/detectors/new-settings.tsx @@ -40,9 +40,13 @@ export default function DetectorNewSettings() { ); } - const project = projectId - ? projects.find(p => p.id === projectId) - : orderBy(projects, ['isMember', 'isBookmarked'], ['desc', 'desc'])[0]; + const sortedProjects = orderBy( + projects, + ['isMember', 'isBookmarked'], + ['desc', 'desc'] + ); + const project = + (projectId ? projects.find(p => p.id === projectId) : null) ?? sortedProjects[0]; if (!project) { return ; diff --git a/static/app/views/insights/common/views/spanSummaryPage/sampleList/index.tsx b/static/app/views/insights/common/views/spanSummaryPage/sampleList/index.tsx index 777fc68af09f5b..4ca9f57c7bd81f 100644 --- a/static/app/views/insights/common/views/spanSummaryPage/sampleList/index.tsx +++ b/static/app/views/insights/common/views/spanSummaryPage/sampleList/index.tsx @@ -13,9 +13,9 @@ import {decodeScalar} from 'sentry/utils/queryString'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {useLocationQuery} from 'sentry/utils/url/useLocationQuery'; import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; -import {useRouter} from 'sentry/utils/useRouter'; import {TraceItemSearchQueryBuilder} from 'sentry/views/explore/components/traceItemSearchQueryBuilder'; import {DATA_TYPE} from 'sentry/views/insights/browser/resources/settings'; import {decodeSubregions} from 'sentry/views/insights/browser/resources/utils/queryParameterDecoders/subregions'; @@ -82,7 +82,7 @@ export function SampleList({groupId, moduleName, transactionRoute, referrer}: Pr }, }); - const router = useRouter(); + const navigate = useNavigate(); const [highlightedSpanId, setHighlightedSpanId] = useState( undefined ); @@ -101,13 +101,16 @@ export function SampleList({groupId, moduleName, transactionRoute, referrer}: Pr ); const handleSearch = (newSpanSearchQuery: string) => { - router.replace({ - pathname: location.pathname, - query: { - ...location.query, - spanSearchQuery: newSpanSearchQuery, + navigate( + { + pathname: location.pathname, + query: { + ...location.query, + spanSearchQuery: newSpanSearchQuery, + }, }, - }); + {replace: true} + ); }; // set additional query filters from the span search bar and the `query` param @@ -146,7 +149,7 @@ export function SampleList({groupId, moduleName, transactionRoute, referrer}: Pr const handleClickSample = useCallback( (span: SpanSample) => { - router.push( + navigate( generateLinkToEventInTraceView({ targetId: span['transaction.span_id'], spanId: span.span_id, @@ -157,7 +160,7 @@ export function SampleList({groupId, moduleName, transactionRoute, referrer}: Pr }) ); }, - [organization, location, router] + [organization, location, navigate] ); const handleMouseOverSample = useCallback( diff --git a/static/app/views/issueDetails/groupDistributions/useDrawerTab.tsx b/static/app/views/issueDetails/groupDistributions/useDrawerTab.tsx index bcfa670a4f22e7..38880fada23917 100644 --- a/static/app/views/issueDetails/groupDistributions/useDrawerTab.tsx +++ b/static/app/views/issueDetails/groupDistributions/useDrawerTab.tsx @@ -1,16 +1,13 @@ -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; +import {parseAsStringLiteral, useQueryState} from 'nuqs'; + import {DrawerTab} from 'sentry/views/issueDetails/groupDistributions/types'; +const tabParser = parseAsStringLiteral(Object.values(DrawerTab)).withDefault( + DrawerTab.TAGS +); + export function useDrawerTab({enabled}: {enabled: boolean}) { - const {getParamValue: getTabParam, setParamValue: setTabParam} = useUrlParams( - 'tab', - DrawerTab.TAGS - ); + const [tab, setTab] = useQueryState('tab', tabParser); - return enabled - ? { - tab: getTabParam() as DrawerTab, - setTab: setTabParam, - } - : {tab: DrawerTab.TAGS, setTab: (_tab: string) => {}}; + return enabled ? {tab, setTab} : {tab: DrawerTab.TAGS, setTab: (_tab: DrawerTab) => {}}; } diff --git a/static/app/views/issueList/actions/index.spec.tsx b/static/app/views/issueList/actions/index.spec.tsx index df622ad112f5e8..779eebeaf8a3d8 100644 --- a/static/app/views/issueList/actions/index.spec.tsx +++ b/static/app/views/issueList/actions/index.spec.tsx @@ -108,24 +108,24 @@ describe('IssueListActions', () => { expect(screen.queryByRole('button', {name: 'Archive'})).not.toBeInTheDocument(); }); - it('shows action buttons when any items are selected', () => { + it('shows action buttons when any items are selected', async () => { render(); - expect(screen.getByRole('button', {name: 'Resolve'})).toBeEnabled(); + expect(await screen.findByRole('button', {name: 'Resolve'})).toBeEnabled(); expect(screen.getByRole('button', {name: 'Archive'})).toBeEnabled(); }); - it('shows select all checkbox as checked when all items are selected', () => { + it('shows select all checkbox as checked when all items are selected', async () => { render(); // When all selected, label changes to "Deselect all" - expect(screen.getByRole('checkbox', {name: 'Deselect all'})).toBeChecked(); + expect(await screen.findByRole('checkbox', {name: 'Deselect all'})).toBeChecked(); }); - it('shows select all checkbox as indeterminate when some items are selected', () => { + it('shows select all checkbox as indeterminate when some items are selected', async () => { render(); - const checkbox = screen.getByRole('checkbox', {name: 'Select all'}); + const checkbox = await screen.findByRole('checkbox', {name: 'Select all'}); expect(checkbox).toBePartiallyChecked(); }); }); diff --git a/static/app/views/issueList/groupListBody.tsx b/static/app/views/issueList/groupListBody.tsx index 1154ad5502b080..fad13644e0ff3b 100644 --- a/static/app/views/issueList/groupListBody.tsx +++ b/static/app/views/issueList/groupListBody.tsx @@ -9,13 +9,13 @@ import {LoadingStreamGroup, StreamGroup} from 'sentry/components/stream/group'; import {SupergroupRow} from 'sentry/components/stream/supergroupRow'; import {GroupStore} from 'sentry/stores/groupStore'; import type {Group} from 'sentry/types/group'; -import {aggregateSupergroupStats} from 'sentry/utils/supergroup/aggregateSupergroupStats'; -import type {SupergroupLookup} from 'sentry/utils/supergroup/useSuperGroups'; import {useApi} from 'sentry/utils/useApi'; import {useMedia} from 'sentry/utils/useMedia'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState'; +import {aggregateSupergroupStats} from 'sentry/views/issueList/supergroups/aggregateSupergroupStats'; import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; +import type {SupergroupLookup} from 'sentry/views/issueList/supergroups/useSuperGroups'; import type {IssueUpdateData} from 'sentry/views/issueList/types'; import {NoGroupsHandler} from './noGroupsHandler'; diff --git a/static/app/views/issueList/issueListTable.tsx b/static/app/views/issueList/issueListTable.tsx index b81579c6ed50ac..b3d2706fadec0d 100644 --- a/static/app/views/issueList/issueListTable.tsx +++ b/static/app/views/issueList/issueListTable.tsx @@ -10,12 +10,12 @@ import {t} from 'sentry/locale'; import type {PageFilters} from 'sentry/types/core'; import {DemoTourElement, DemoTourStep} from 'sentry/utils/demoMode/demoTours'; import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry'; -import type {SupergroupLookup} from 'sentry/utils/supergroup/useSuperGroups'; import {useLocation} from 'sentry/utils/useLocation'; import {IssueListActions} from 'sentry/views/issueList/actions'; import {GroupListBody} from 'sentry/views/issueList/groupListBody'; import {IssueSelectionProvider} from 'sentry/views/issueList/issueSelectionContext'; import {NewViewEmptyState} from 'sentry/views/issueList/newViewEmptyState'; +import type {SupergroupLookup} from 'sentry/views/issueList/supergroups/useSuperGroups'; import type {IssueUpdateData} from 'sentry/views/issueList/types'; interface IssueListTableProps { diff --git a/static/app/views/issueList/overview.tsx b/static/app/views/issueList/overview.tsx index f709d8dd7c5bb1..7243ede9b5c029 100644 --- a/static/app/views/issueList/overview.tsx +++ b/static/app/views/issueList/overview.tsx @@ -38,7 +38,6 @@ import type {RequestError} from 'sentry/utils/requestError/requestError'; import {useDisableRouteAnalytics} from 'sentry/utils/routeAnalytics/useDisableRouteAnalytics'; import {useRouteAnalyticsEventNames} from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames'; import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; -import {useSuperGroups} from 'sentry/utils/supergroup/useSuperGroups'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useApi} from 'sentry/utils/useApi'; import {useLocation} from 'sentry/utils/useLocation'; @@ -49,6 +48,7 @@ import {usePrevious} from 'sentry/utils/usePrevious'; import {IssueListTable} from 'sentry/views/issueList/issueListTable'; import {IssuesDataConsentBanner} from 'sentry/views/issueList/issuesDataConsentBanner'; import {IssueViewsHeader} from 'sentry/views/issueList/issueViewsHeader'; +import {useSuperGroups} from 'sentry/views/issueList/supergroups/useSuperGroups'; import type {IssueUpdateData} from 'sentry/views/issueList/types'; import {parseIssuePrioritySearch} from 'sentry/views/issueList/utils/parseIssuePrioritySearch'; import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; diff --git a/static/app/views/issueList/supergroups/aggregateSupergroupStats.spec.ts b/static/app/views/issueList/supergroups/aggregateSupergroupStats.spec.ts new file mode 100644 index 00000000000000..abe0f467ac4e80 --- /dev/null +++ b/static/app/views/issueList/supergroups/aggregateSupergroupStats.spec.ts @@ -0,0 +1,141 @@ +import {GroupFixture} from 'sentry-fixture/group'; + +import type {Group} from 'sentry/types/group'; + +import {aggregateSupergroupStats} from './aggregateSupergroupStats'; + +describe('aggregateSupergroupStats', () => { + it('returns null for empty groups', () => { + expect(aggregateSupergroupStats([], '24h')).toBeNull(); + }); + + it('sums event and user counts', () => { + const groups = [ + GroupFixture({count: '10', userCount: 3}), + GroupFixture({count: '20', userCount: 7}), + ]; + const result = aggregateSupergroupStats(groups, '24h'); + expect(result?.eventCount).toBe(30); + expect(result?.userCount).toBe(10); + }); + + it('takes min firstSeen and max lastSeen', () => { + const groups = [ + GroupFixture({firstSeen: '2024-01-05T00:00:00Z', lastSeen: '2024-01-10T00:00:00Z'}), + GroupFixture({firstSeen: '2024-01-01T00:00:00Z', lastSeen: '2024-01-15T00:00:00Z'}), + ]; + const result = aggregateSupergroupStats(groups, '24h'); + expect(result?.firstSeen).toBe('2024-01-01T00:00:00Z'); + expect(result?.lastSeen).toBe('2024-01-15T00:00:00Z'); + }); + + it('point-wise sums stats timeseries', () => { + const groups = [ + GroupFixture({ + stats: { + '24h': [ + [1000, 1], + [2000, 2], + ], + }, + }), + GroupFixture({ + stats: { + '24h': [ + [1000, 3], + [2000, 4], + ], + }, + }), + ]; + const result = aggregateSupergroupStats(groups, '24h'); + expect(result?.mergedStats).toEqual([ + [1000, 4], + [2000, 6], + ]); + }); + + it('returns null filtered fields when no groups have filters', () => { + const groups = [GroupFixture({filtered: null})]; + const result = aggregateSupergroupStats(groups, '24h'); + expect(result?.filteredEventCount).toBeNull(); + expect(result?.filteredUserCount).toBeNull(); + expect(result?.mergedFilteredStats).toBeNull(); + }); + + it('treats missing counts as zero when stats have not loaded', () => { + const result = aggregateSupergroupStats( + [GroupFixture({count: '10', userCount: 3}), {} as Group], + '24h' + ); + expect(result?.eventCount).toBe(10); + expect(result?.userCount).toBe(3); + }); + + it('aggregates filtered stats separately', () => { + const groups = [ + GroupFixture({ + count: '100', + userCount: 50, + stats: { + '24h': [ + [1000, 10], + [2000, 20], + ], + }, + filtered: { + count: '30', + userCount: 15, + firstSeen: '2024-01-01T00:00:00Z', + lastSeen: '2024-01-10T00:00:00Z', + stats: { + '24h': [ + [1000, 3], + [2000, 5], + ], + }, + }, + }), + GroupFixture({ + count: '200', + userCount: 80, + stats: { + '24h': [ + [1000, 40], + [2000, 60], + ], + }, + filtered: { + count: '70', + userCount: 25, + firstSeen: '2024-01-02T00:00:00Z', + lastSeen: '2024-01-12T00:00:00Z', + stats: { + '24h': [ + [1000, 7], + [2000, 15], + ], + }, + }, + }), + ]; + + const result = aggregateSupergroupStats(groups, '24h'); + + // Total stats + expect(result?.eventCount).toBe(300); + expect(result?.userCount).toBe(130); + expect(result?.mergedStats).toEqual([ + [1000, 50], + [2000, 80], + ]); + + // Filtered stats + expect(result?.filteredEventCount).toBe(100); + expect(result?.filteredUserCount).toBe(40); + expect(result?.mergedFilteredStats).toEqual([ + [1000, 10], + [2000, 20], + ]); + }); +}); diff --git a/static/app/views/issueList/supergroups/aggregateSupergroupStats.ts b/static/app/views/issueList/supergroups/aggregateSupergroupStats.ts new file mode 100644 index 00000000000000..42aa4e46ad7e2d --- /dev/null +++ b/static/app/views/issueList/supergroups/aggregateSupergroupStats.ts @@ -0,0 +1,95 @@ +import type {TimeseriesValue} from 'sentry/types/core'; +import type {Group} from 'sentry/types/group'; + +export interface AggregatedSupergroupStats { + eventCount: number; + filteredEventCount: number | null; + filteredUserCount: number | null; + firstSeen: string | null; + lastSeen: string | null; + mergedFilteredStats: TimeseriesValue[] | null; + mergedStats: TimeseriesValue[]; + userCount: number; +} + +function addTimeseries( + acc: TimeseriesValue[] | null, + series: TimeseriesValue[] +): TimeseriesValue[] { + if (acc === null) { + return series.map(([ts, val]) => [ts, val] as TimeseriesValue); + } + for (let i = 0; i < Math.min(acc.length, series.length); i++) { + acc[i] = [acc[i]![0], acc[i]![1] + series[i]![1]]; + } + return acc; +} + +/** + * Aggregate stats from member groups for display in a supergroup row. + * Sums event/user counts, takes min firstSeen and max lastSeen, + * and point-wise sums the trend data. + * + * When groups have filtered stats (from search filters), those are + * aggregated separately so the supergroup row can show total vs matching. + */ +export function aggregateSupergroupStats( + groups: Group[], + statsPeriod: string +): AggregatedSupergroupStats | null { + if (groups.length === 0) { + return null; + } + + let eventCount = 0; + let userCount = 0; + let filteredEventCount: number | null = null; + let filteredUserCount: number | null = null; + let firstSeen: string | null = null; + let lastSeen: string | null = null; + let mergedStats: TimeseriesValue[] | null = null; + let mergedFilteredStats: TimeseriesValue[] | null = null; + + for (const group of groups) { + eventCount += parseInt(group.count, 10) || 0; + userCount += group.userCount ?? 0; + + if (group.filtered) { + filteredEventCount ??= 0; + filteredUserCount ??= 0; + filteredEventCount += parseInt(group.filtered.count, 10) || 0; + filteredUserCount += group.filtered.userCount ?? 0; + + const filteredStats = group.filtered.stats?.[statsPeriod]; + if (filteredStats) { + mergedFilteredStats = addTimeseries(mergedFilteredStats, filteredStats); + } + } + + const gFirstSeen = group.lifetime?.firstSeen ?? group.firstSeen; + if (!firstSeen || gFirstSeen < firstSeen) { + firstSeen = gFirstSeen; + } + + const gLastSeen = group.lifetime?.lastSeen ?? group.lastSeen; + if (!lastSeen || gLastSeen > lastSeen) { + lastSeen = gLastSeen; + } + + const stats = group.stats?.[statsPeriod]; + if (stats) { + mergedStats = addTimeseries(mergedStats, stats); + } + } + + return { + eventCount, + userCount, + filteredEventCount, + filteredUserCount, + firstSeen, + lastSeen, + mergedStats: mergedStats ?? [], + mergedFilteredStats, + }; +} diff --git a/static/app/utils/supergroup/useSuperGroups.spec.tsx b/static/app/views/issueList/supergroups/useSuperGroups.spec.tsx similarity index 96% rename from static/app/utils/supergroup/useSuperGroups.spec.tsx rename to static/app/views/issueList/supergroups/useSuperGroups.spec.tsx index 123f5cc90573ea..17d271436d823e 100644 --- a/static/app/utils/supergroup/useSuperGroups.spec.tsx +++ b/static/app/views/issueList/supergroups/useSuperGroups.spec.tsx @@ -2,8 +2,8 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; -import {useSuperGroups} from 'sentry/utils/supergroup/useSuperGroups'; import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; +import {useSuperGroups} from 'sentry/views/issueList/supergroups/useSuperGroups'; const organization = OrganizationFixture({features: ['top-issues-ui']}); const API_URL = `/organizations/${organization.slug}/seer/supergroups/by-group/`; diff --git a/static/app/utils/supergroup/useSuperGroups.tsx b/static/app/views/issueList/supergroups/useSuperGroups.tsx similarity index 100% rename from static/app/utils/supergroup/useSuperGroups.tsx rename to static/app/views/issueList/supergroups/useSuperGroups.tsx diff --git a/static/app/views/navigation/index.desktop.spec.tsx b/static/app/views/navigation/index.desktop.spec.tsx index 9753ff54febea8..414d53ea7b90de 100644 --- a/static/app/views/navigation/index.desktop.spec.tsx +++ b/static/app/views/navigation/index.desktop.spec.tsx @@ -851,9 +851,8 @@ describe('desktop navigation', () => { await userEvent.hover(screen.getByRole('link', {name: 'Explore'})); - expect( - await within(secondaryNav).findByRole('link', {name: 'Traces'}) - ).toBeInTheDocument(); + // Re-query secondary nav because AnimatePresence remounts it with a new key + expect(await screen.findByRole('link', {name: 'Traces'})).toBeInTheDocument(); }); it('shows hovered group content in the peek view when sidebar is collapsed', async () => { diff --git a/static/app/views/navigation/primary/helpMenu.spec.tsx b/static/app/views/navigation/primary/helpMenu.spec.tsx new file mode 100644 index 00000000000000..8206341d812caf --- /dev/null +++ b/static/app/views/navigation/primary/helpMenu.spec.tsx @@ -0,0 +1,82 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {ConfigStore} from 'sentry/stores/configStore'; +import * as intercom from 'sentry/utils/intercom'; +import * as zendesk from 'sentry/utils/zendesk'; +import {PrimaryNavigationHelpMenu} from 'sentry/views/navigation/primary/helpMenu'; + +jest.mock('sentry/utils/intercom', () => ({ + showIntercom: jest.fn(), +})); + +jest.mock('sentry/utils/zendesk', () => ({ + hasZendesk: jest.fn(), + activateZendesk: jest.fn(), +})); + +jest.mock('sentry/views/navigation/navigationTour', () => ({ + useNavigationTour: jest.fn(() => ({ + startTour: jest.fn(), + })), + NavigationTourReminder: ({children}: {children: React.ReactNode}) => ( +
{children}
+ ), +})); + +describe('PrimaryNavigationHelpMenu', () => { + beforeEach(() => { + jest.clearAllMocks(); + ConfigStore.set('supportEmail', 'support@sentry.io'); + }); + + it('opens Intercom when feature flag is enabled', async () => { + const organization = OrganizationFixture({ + features: ['intercom-support'], + }); + + render(, {organization}); + + await userEvent.click(screen.getByRole('button', {name: 'Help'})); + + // Click Contact Support in the menu + await userEvent.click(screen.getByRole('menuitemradio', {name: 'Contact Support'})); + + expect(intercom.showIntercom).toHaveBeenCalledWith(organization.slug); + expect(zendesk.activateZendesk).not.toHaveBeenCalled(); + }); + + it('opens Zendesk when feature flag is disabled and Zendesk is available', async () => { + jest.mocked(zendesk.hasZendesk).mockReturnValue(true); + + const organization = OrganizationFixture({ + features: [], + }); + + render(, {organization}); + + await userEvent.click(screen.getByRole('button', {name: 'Help'})); + + // Click Contact Support in the menu + await userEvent.click(screen.getByRole('menuitemradio', {name: 'Contact Support'})); + + expect(zendesk.activateZendesk).toHaveBeenCalled(); + expect(intercom.showIntercom).not.toHaveBeenCalled(); + }); + + it('falls back to mailto when neither Intercom nor Zendesk is available', async () => { + jest.mocked(zendesk.hasZendesk).mockReturnValue(false); + + const organization = OrganizationFixture({ + features: [], + }); + + render(, {organization}); + + await userEvent.click(screen.getByRole('button', {name: 'Help'})); + + const contactSupport = screen.getByRole('menuitemradio', {name: 'Contact Support'}); + expect(contactSupport).toHaveAttribute('href', 'mailto:support@sentry.io'); + }); +}); diff --git a/static/app/views/navigation/primary/helpMenu.tsx b/static/app/views/navigation/primary/helpMenu.tsx index 93efd4cc342b58..fc96c39e49f0cf 100644 --- a/static/app/views/navigation/primary/helpMenu.tsx +++ b/static/app/views/navigation/primary/helpMenu.tsx @@ -1,3 +1,5 @@ +import {useEffect} from 'react'; + import {Flex} from '@sentry/scraps/layout'; import {openHelpSearchModal} from 'sentry/actionCreators/modal'; @@ -22,6 +24,7 @@ import {ConfigStore} from 'sentry/stores/configStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import type {Organization} from 'sentry/types/organization'; import {trackAnalytics} from 'sentry/utils/analytics'; +import {showIntercom} from 'sentry/utils/intercom'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; import {useOrganization} from 'sentry/utils/useOrganization'; import {activateZendesk, hasZendesk} from 'sentry/utils/zendesk'; @@ -32,6 +35,58 @@ import { import {PrimaryNavigation} from 'sentry/views/navigation/primary/components'; import {useHasPageFrameFeature} from 'sentry/views/navigation/useHasPageFrameFeature'; +function getContactSupportItem(organization: Organization): MenuItemProps | null { + const supportEmail = ConfigStore.get('supportEmail'); + + if (!supportEmail) { + return null; + } + + const hasIntercom = organization.features.includes('intercom-support'); + + // Use Intercom if feature flag is enabled (lazily initialized on first click) + if (hasIntercom) { + return { + key: 'support', + label: t('Contact Support'), + async onAction() { + trackAnalytics('intercom_link.clicked', { + organization, + source: 'sidebar', + }); + try { + await showIntercom(organization.slug); + } catch { + // Fall back to mailto + window.location.href = `mailto:${supportEmail}`; + } + }, + }; + } + + // Fall back to Zendesk if available + if (hasZendesk()) { + return { + key: 'support', + label: t('Contact Support'), + onAction() { + activateZendesk(); + trackAnalytics('zendesk_link.clicked', { + organization, + source: 'sidebar', + }); + }, + }; + } + + // Fall back to mailto + return { + key: 'support', + label: t('Contact Support'), + externalHref: `mailto:${supportEmail}`, + }; +} + export function PrimaryNavigationHelpMenu() { const organization = useOrganization(); const contactSupportItem = getContactSupportItem(organization); @@ -39,6 +94,13 @@ export function PrimaryNavigationHelpMenu() { const {startTour} = useNavigationTour(); const {privacyUrl, termsUrl} = useLegacyStore(ConfigStore); const hasPageFrame = useHasPageFrameFeature(); + const hasIntercom = organization.features.includes('intercom-support'); + + useEffect(() => { + if (hasIntercom) { + trackAnalytics('intercom_link.viewed', {organization, source: 'sidebar'}); + } + }, [hasIntercom, organization]); const items = hasPageFrame ? getPageFrameItems({contactSupportItem, privacyUrl, termsUrl}) @@ -309,31 +371,3 @@ function getLegacyItems({ }, ]; } - -function getContactSupportItem(organization: Organization): MenuItemProps | null { - const supportEmail = ConfigStore.get('supportEmail'); - - if (!supportEmail) { - return null; - } - - if (hasZendesk()) { - return { - key: 'support', - label: t('Contact Support'), - onAction() { - activateZendesk(); - trackAnalytics('zendesk_link.clicked', { - organization, - source: 'sidebar', - }); - }, - }; - } - - return { - key: 'support', - label: t('Contact Support'), - externalHref: `mailto:${supportEmail}`, - }; -} diff --git a/static/app/views/navigation/secondary/components.tsx b/static/app/views/navigation/secondary/components.tsx index b3ad0cb7bc95a8..b6c8fb6d6440a6 100644 --- a/static/app/views/navigation/secondary/components.tsx +++ b/static/app/views/navigation/secondary/components.tsx @@ -49,7 +49,6 @@ import { } from 'sentry/icons'; import {t} from 'sentry/locale'; import {trackAnalytics} from 'sentry/utils/analytics'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -631,13 +630,13 @@ function Collapsible(props: CollapsibleProps) { initial="collapsed" animate="expanded" exit="collapsed" - transition={testableTransition({ + transition={{ type: 'spring', damping: 50, stiffness: 600, bounce: 0, visualDuration: 0.4, - })} + }} > {/* We need to wrap the children in a div to prevent the parent's flex-direction: column-reverse diff --git a/static/app/views/onboarding/components/fallingError.tsx b/static/app/views/onboarding/components/fallingError.tsx index 74b816d92f86ba..18f3e8e7a2bfb0 100644 --- a/static/app/views/onboarding/components/fallingError.tsx +++ b/static/app/views/onboarding/components/fallingError.tsx @@ -1,8 +1,6 @@ import {Component} from 'react'; import {motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - type RenderProps = { fallCount: number; fallingError: React.ReactNode; @@ -49,11 +47,11 @@ export class FallingError extends Component { originY: '0', opacity: [1, 1, 1], rotateZ: [8, -8, 8], - transition: testableTransition({ + transition: { repeat: Infinity, repeatType: 'loop', duration: 4, - }), + }, }, falling: { originY: '50%', diff --git a/static/app/views/onboarding/components/firstEventFooter.tsx b/static/app/views/onboarding/components/firstEventFooter.tsx index fdc450e17ed6ec..bc49c4566ca0f7 100644 --- a/static/app/views/onboarding/components/firstEventFooter.tsx +++ b/static/app/views/onboarding/components/firstEventFooter.tsx @@ -16,7 +16,6 @@ import type {Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {useApiQuery} from 'sentry/utils/queryClient'; -import {testableTransition} from 'sentry/utils/testableTransition'; import CreateSampleEventButton from 'sentry/views/onboarding/createSampleEventButton'; import {useOnboardingSidebar} from 'sentry/views/onboarding/useOnboardingSidebar'; @@ -122,10 +121,10 @@ export function FirstEventFooter({ animate: { opacity: 1, y: 0, - transition: testableTransition({ + transition: { when: 'beforeChildren', staggerChildren: 0.35, - }), + }, }, exit: {opacity: 0, y: 10}, }} @@ -133,16 +132,9 @@ export function FirstEventFooter({ {project?.firstEvent ? ( ) : ( - + )} - + {project?.firstEvent ? t('Error Received') : t('Waiting for error')} diff --git a/static/app/views/onboarding/components/genericFooter.tsx b/static/app/views/onboarding/components/genericFooter.tsx index 90b3296637c8a5..9bc94e11675678 100644 --- a/static/app/views/onboarding/components/genericFooter.tsx +++ b/static/app/views/onboarding/components/genericFooter.tsx @@ -1,17 +1,15 @@ import styled from '@emotion/styled'; import {motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - export const GenericFooter = styled((props: React.ComponentProps) => ( ))` diff --git a/static/app/views/onboarding/components/newWelcome.tsx b/static/app/views/onboarding/components/newWelcome.tsx index 76df843dc79cbe..c4fb2532233f97 100644 --- a/static/app/views/onboarding/components/newWelcome.tsx +++ b/static/app/views/onboarding/components/newWelcome.tsx @@ -18,7 +18,6 @@ import { IconWarning, } from 'sentry/icons'; import {t} from 'sentry/locale'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {GenericFooter} from 'sentry/views/onboarding/components/genericFooter'; import { NewWelcomeProductCard, @@ -44,10 +43,10 @@ const STAGGER_CONTAINER: MotionProps = { variants: { initial: {}, animate: { - transition: testableTransition({ + transition: { staggerChildren: 0.1, delayChildren: 0.1, - }), + }, }, exit: {}, }, @@ -56,9 +55,9 @@ const STAGGER_CONTAINER: MotionProps = { const STAGGER_CHILDREN = { initial: {}, animate: { - transition: testableTransition({ + transition: { staggerChildren: 0.08, - }), + }, }, }; diff --git a/static/app/views/onboarding/components/pageCorners.tsx b/static/app/views/onboarding/components/pageCorners.tsx index 20169eb117b3b0..e21c41b0985057 100644 --- a/static/app/views/onboarding/components/pageCorners.tsx +++ b/static/app/views/onboarding/components/pageCorners.tsx @@ -1,18 +1,16 @@ import type {HTMLAttributes} from 'react'; import styled from '@emotion/styled'; -import type {MotionNodeAnimationOptions} from 'framer-motion'; +import type {MotionNodeAnimationOptions, Transition} from 'framer-motion'; import {motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - type Props = { animateVariant: MotionNodeAnimationOptions['animate']; } & HTMLAttributes; export function PageCorners({animateVariant, ...rest}: Props) { - const baseTransition = testableTransition({type: 'spring', duration: 0.8}); + const baseTransition: Transition = {type: 'spring', duration: 0.8}; // Consistent enter delay for visible variants - const delayedTransition = testableTransition({type: 'spring', duration: 0.8, delay: 1}); + const delayedTransition: Transition = {type: 'spring', duration: 0.8, delay: 1}; return ( & {step: number}) => ( ) diff --git a/static/app/views/onboarding/components/stepper.tsx b/static/app/views/onboarding/components/stepper.tsx index 7e977cd783048c..7dca720ce05970 100644 --- a/static/app/views/onboarding/components/stepper.tsx +++ b/static/app/views/onboarding/components/stepper.tsx @@ -1,8 +1,6 @@ import styled from '@emotion/styled'; import {motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - const StepperContainer = styled('div')` display: flex; flex-direction: row; @@ -44,11 +42,11 @@ export function Stepper({currentStepIndex, numSteps, onClick, ...props}: Props) {currentStepIndex === i && ( diff --git a/static/app/views/onboarding/components/welcomeBackground.tsx b/static/app/views/onboarding/components/welcomeBackground.tsx index 96c30c268f76d5..f10260f714bd44 100644 --- a/static/app/views/onboarding/components/welcomeBackground.tsx +++ b/static/app/views/onboarding/components/welcomeBackground.tsx @@ -7,8 +7,6 @@ import BugBImage from 'sentry-images/spot/seer-config-bug-1.svg'; import {Image} from '@sentry/scraps/image'; -import {testableTransition} from 'sentry/utils/testableTransition'; - export function WelcomeBackground() { return ( @@ -35,11 +33,11 @@ function WelcomeBackgroundImages() { animate: { opacity: 1, scale: 1, - transition: testableTransition({duration: 0.5}), + transition: {duration: 0.5}, }, exit: {y: -120, opacity: 0}, }} - transition={testableTransition({duration: 0.9})} + transition={{duration: 0.9}} > @@ -52,13 +50,13 @@ function WelcomeBackgroundImages() { animate: { opacity: 1, scale: 1, - transition: testableTransition({duration: 0.5}), + transition: {duration: 0.5}, }, exit: {y: -200, opacity: 0}, }} - transition={testableTransition({ + transition={{ duration: 1.1, - })} + }} > @@ -73,7 +71,7 @@ export function WelcomeBackgroundNewUi() { animate: {}, exit: {}, }} - transition={testableTransition({staggerChildren: 0.2})} + transition={{staggerChildren: 0.2}} > diff --git a/static/app/views/onboarding/consts.ts b/static/app/views/onboarding/consts.ts index 7d6ffe3eb5815f..aaba7e2c5e8f13 100644 --- a/static/app/views/onboarding/consts.ts +++ b/static/app/views/onboarding/consts.ts @@ -1,7 +1,5 @@ import type {MotionProps} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - export const ONBOARDING_WELCOME_SCREEN_SOURCE = 'targeted_onboarding'; // Child element animation - used by each staggered item @@ -11,7 +9,7 @@ export const ONBOARDING_WELCOME_STAGGER_ITEM: MotionProps = { animate: { opacity: 1, y: 0, - transition: testableTransition({duration: 0.4}), + transition: {duration: 0.4}, }, exit: {opacity: 0, y: -10}, }, diff --git a/static/app/views/onboarding/onboarding.tsx b/static/app/views/onboarding/onboarding.tsx index 6bb306970484ae..b92c9bb1ce7f1a 100644 --- a/static/app/views/onboarding/onboarding.tsx +++ b/static/app/views/onboarding/onboarding.tsx @@ -23,7 +23,6 @@ import type {OnboardingSelectedSDK} from 'sentry/types/onboarding'; import type {PlatformKey} from 'sentry/types/project'; import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -143,9 +142,9 @@ function OnboardingStepVariable(props: PropsWithChildren @@ -351,12 +350,11 @@ export function OnboardingWithoutContext() { ) => ( animate: {clipPath: 'inset(0% 0% 0% 0%)', opacity: 1}, exit: {opacity: 0}, }} - transition={testableTransition({ + transition={{ duration: 0.3, - })} + }} {...props} /> ))<{step: number}>` diff --git a/static/app/views/relocation/encryptBackup.tsx b/static/app/views/relocation/encryptBackup.tsx index 0e7c2f493f956d..f0dd02072fb680 100644 --- a/static/app/views/relocation/encryptBackup.tsx +++ b/static/app/views/relocation/encryptBackup.tsx @@ -4,7 +4,6 @@ import {CodeBlock} from '@sentry/scraps/code'; import {IconTerminal} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {ContinueButton} from 'sentry/views/relocation/components/continueButton'; import {StepHeading} from 'sentry/views/relocation/components/stepHeading'; import {Wrapper} from 'sentry/views/relocation/components/wrapper'; @@ -20,7 +19,6 @@ export function EncryptBackup(props: StepProps) { {t('Create an encrypted backup of your current self-hosted instance')} {t('Basic information needed to get started')} {t('Your relocation is under way!')} {t("Save Sentry's public key to your machine")} {publicKey ? ( ) : ( ) = animate="animate" exit="exit" variants={{animate: {}}} - transition={testableTransition({ + transition={{ staggerChildren: 0.2, - })} + }} {...props} /> ))` diff --git a/static/app/views/relocation/uploadBackup.tsx b/static/app/views/relocation/uploadBackup.tsx index c10015f0c40083..b2d2db016a80b2 100644 --- a/static/app/views/relocation/uploadBackup.tsx +++ b/static/app/views/relocation/uploadBackup.tsx @@ -9,7 +9,6 @@ import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicato import {Client} from 'sentry/api'; import {IconDelete, IconFile, IconUpload} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {useApi} from 'sentry/utils/useApi'; import {useUser} from 'sentry/utils/useUser'; import {StepHeading} from 'sentry/views/relocation/components/stepHeading'; @@ -112,7 +111,6 @@ export function UploadBackup({relocationState, onComplete}: StepProps) { {t('Upload Tarball to begin the relocation process')} - { - setParamValue(tab); - }} - > + setActiveTab(tab)}> {Object.entries(TABS).map(([tab, label]) => ( {label} diff --git a/static/app/views/replays/detail/network/index.tsx b/static/app/views/replays/detail/network/index.tsx index e3971f938cb8fb..bb1cfde508dea9 100644 --- a/static/app/views/replays/detail/network/index.tsx +++ b/static/app/views/replays/detail/network/index.tsx @@ -150,8 +150,7 @@ export function NetworkList() { }, [isNetworkDetailsSetup, organization]), }); - const selectedItem = - selectedIndex === '' ? null : (items[Number(selectedIndex)] ?? null); + const selectedItem = selectedIndex === null ? null : (items[selectedIndex] ?? null); return ( diff --git a/static/app/views/replays/detail/network/networkTableCell.tsx b/static/app/views/replays/detail/network/networkTableCell.tsx index 5155e0822a6370..00dc835a34e564 100644 --- a/static/app/views/replays/detail/network/networkTableCell.tsx +++ b/static/app/views/replays/detail/network/networkTableCell.tsx @@ -1,4 +1,5 @@ import type {ComponentProps, CSSProperties} from 'react'; +import {parseAsInteger, useQueryState} from 'nuqs'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -16,7 +17,6 @@ import { getResponseBodySize, } from 'sentry/utils/replays/resourceFrame'; import type {SpanFrame} from 'sentry/utils/replays/types'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {TimestampButton} from 'sentry/views/replays/detail/timestampButton'; import {operationName} from 'sentry/views/replays/detail/utils'; @@ -47,8 +47,8 @@ export function NetworkTableCell({ // Rows include the sortable header, the dataIndex does not const dataIndex = rowIndex - 1; - const {getParamValue} = useUrlParams('n_detail_row', ''); - const isSelected = getParamValue() === String(dataIndex); + const [detailRow] = useQueryState('n_detail_row', parseAsInteger); + const isSelected = detailRow === dataIndex; const method = getFrameMethod(frame); const statusCode = getFrameStatus(frame); diff --git a/static/app/views/seerExplorer/explorerPanel.spec.tsx b/static/app/views/seerExplorer/explorerPanel.spec.tsx index 93060a94aeb53a..d1c50f45289776 100644 --- a/static/app/views/seerExplorer/explorerPanel.spec.tsx +++ b/static/app/views/seerExplorer/explorerPanel.spec.tsx @@ -125,15 +125,15 @@ describe('ExplorerPanel', () => { }); describe('Feature Flag and Organization Checks', () => { - it('renders when feature flag and open membership are enabled', () => { + it('renders when feature flag and open membership are enabled', async () => { renderWithPanelContext(, true, {organization}); expect( - screen.getByText(/Ask Seer anything about your application./) + await screen.findByText(/Ask Seer anything about your application./) ).toBeInTheDocument(); }); - it('does not render when feature flag is disabled', () => { + it('does not render when feature flag is disabled', async () => { const disabledOrg = OrganizationFixture({ features: [], hideAiFeatures: false, @@ -144,10 +144,10 @@ describe('ExplorerPanel', () => { organization: disabledOrg, }); - expect(container).toBeEmptyDOMElement(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); }); - it('does not render when AI features are hidden', () => { + it('does not render when AI features are hidden', async () => { const disabledOrg = OrganizationFixture({ features: ['seer-explorer'], hideAiFeatures: true, @@ -158,10 +158,10 @@ describe('ExplorerPanel', () => { organization: disabledOrg, }); - expect(container).toBeEmptyDOMElement(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); }); - it('does not render when open membership is disabled', () => { + it('does not render when open membership is disabled', async () => { const disabledOrg = OrganizationFixture({ features: ['seer-explorer'], hideAiFeatures: false, @@ -172,28 +172,30 @@ describe('ExplorerPanel', () => { organization: disabledOrg, }); - expect(container).toBeEmptyDOMElement(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); }); }); describe('Empty State', () => { - it('shows empty state when no messages exist', () => { + it('shows empty state when no messages exist', async () => { renderWithPanelContext(, true, {organization}); expect( - screen.getByText(/Ask Seer anything about your application./) + await screen.findByText(/Ask Seer anything about your application./) ).toBeInTheDocument(); }); - it('shows input section in empty state', () => { + it('shows input section in empty state', async () => { renderWithPanelContext(, true, {organization}); expect( - screen.getByPlaceholderText('Type your message or / command and press Enter ↵') + await screen.findByPlaceholderText( + 'Type your message or / command and press Enter ↵' + ) ).toBeInTheDocument(); }); - it('shows error when hook returns isError=true', () => { + it('shows error when hook returns isError=true', async () => { const useSeerExplorerSpy = jest .spyOn(useSeerExplorerModule, 'useSeerExplorer') .mockReturnValue({ @@ -220,7 +222,7 @@ describe('ExplorerPanel', () => { renderWithPanelContext(, true, {organization}); expect( - screen.getByText('Error loading this session (ID=123).') + await screen.findByText('Error loading this session (ID=123).') ).toBeInTheDocument(); expect( screen.queryByText(/Ask Seer anything about your application./) @@ -231,7 +233,7 @@ describe('ExplorerPanel', () => { }); describe('Messages Display', () => { - it('renders messages when session data exists', () => { + it('renders messages when session data exists', async () => { const mockSessionData = { blocks: [ { @@ -283,7 +285,7 @@ describe('ExplorerPanel', () => { renderWithPanelContext(, true, {organization}); - expect(screen.getByText('What is this error?')).toBeInTheDocument(); + expect(await screen.findByText('What is this error?')).toBeInTheDocument(); expect( screen.getByText('This error indicates a null pointer exception.') ).toBeInTheDocument(); @@ -533,19 +535,21 @@ describe('ExplorerPanel', () => { openMembership: true, }); - it('does not render the toggle when the feature flag is disabled', () => { + it('does not render the toggle when the feature flag is disabled', async () => { renderWithPanelContext(, true, {organization}); + // Wait for effects to settle before asserting absence + await screen.findByTestId('seer-explorer-input'); expect( screen.queryByRole('checkbox', {name: 'Toggle context engine'}) ).not.toBeInTheDocument(); }); - it('renders the toggle when the feature flag is enabled', () => { + it('renders the toggle when the feature flag is enabled', async () => { renderWithPanelContext(, true, {organization: orgWithFlag}); expect( - screen.getByRole('checkbox', {name: 'Toggle context engine'}) + await screen.findByRole('checkbox', {name: 'Toggle context engine'}) ).toBeInTheDocument(); }); @@ -623,20 +627,20 @@ describe('ExplorerPanel', () => { }); describe('Visibility Control', () => { - it('renders when isVisible=true', () => { + it('renders when isVisible=true', async () => { renderWithPanelContext(, true, {organization}); - expect(screen.getByTestId('seer-explorer-input')).toBeInTheDocument(); + expect(await screen.findByTestId('seer-explorer-input')).toBeInTheDocument(); }); - it('can handle visibility changes', () => { + it('can handle visibility changes', async () => { const {rerenderWithOpen} = renderWithPanelContext(, false, { organization, }); rerenderWithOpen(true); - expect(screen.getByTestId('seer-explorer-input')).toBeInTheDocument(); + expect(await screen.findByTestId('seer-explorer-input')).toBeInTheDocument(); }); }); }); diff --git a/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx b/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx index 6239aed56f6931..99f37b62482238 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByType.spec.tsx @@ -326,6 +326,7 @@ describe('NotificationSettingsByType', () => { 'continuous-profiling-billing', 'seer-billing', 'logs-billing', + 'expose-category-trace-metric-byte', 'seer-user-billing-launch', ], }); @@ -501,6 +502,7 @@ describe('NotificationSettingsByType', () => { // No continuous-profiling-billing feature // No seer-billing feature // No logs-billing feature + // No expose-category-trace-metric-byte feature ], }); renderComponent({ @@ -525,6 +527,7 @@ describe('NotificationSettingsByType', () => { expect(screen.queryByText('Transactions')).not.toBeInTheDocument(); expect(screen.queryByText('Seer Budget')).not.toBeInTheDocument(); expect(screen.queryByText('Logs')).not.toBeInTheDocument(); + expect(screen.queryByText('Metrics (Bytes)')).not.toBeInTheDocument(); expect(screen.queryByText('Active Contributors')).not.toBeInTheDocument(); }); }); diff --git a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx index 4e9f207b0fa789..9d55ec256598b6 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx @@ -251,6 +251,10 @@ export function NotificationSettingsByType({notificationType}: Props) { organization.features?.includes('logs-billing') ); + const hasTraceMetricsBilling = organizations.some(organization => + organization.features?.includes('expose-category-trace-metric-byte') + ); + const hasSeerUserBilling = organizations.some(organization => organization.features?.includes('seer-user-billing-launch') ); @@ -282,6 +286,9 @@ export function NotificationSettingsByType({notificationType}: Props) { if (field.name.startsWith('quotaLogBytes') && !includeLogs) { return false; } + if (field.name.startsWith('quotaTraceMetricBytes') && !hasTraceMetricsBilling) { + return false; + } if (field.name.startsWith('quotaSeerUsers') && !hasSeerUserBilling) { return false; } diff --git a/static/app/views/settings/project/projectReplays.tsx b/static/app/views/settings/project/projectReplays.tsx index eb5bc4bb7baa0d..5c34ca6cd1fcd4 100644 --- a/static/app/views/settings/project/projectReplays.tsx +++ b/static/app/views/settings/project/projectReplays.tsx @@ -1,4 +1,5 @@ import styled from '@emotion/styled'; +import {parseAsStringLiteral, useQueryState} from 'nuqs'; import {z} from 'zod'; import {LinkButton} from '@sentry/scraps/button'; @@ -14,7 +15,6 @@ import {t, tct} from 'sentry/locale'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {Project} from 'sentry/types/project'; import {fetchMutation} from 'sentry/utils/queryClient'; -import {useUrlParams} from 'sentry/utils/url/useUrlParams'; import {useOrganization} from 'sentry/utils/useOrganization'; import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; import {ProjectPermissionAlert} from 'sentry/views/settings/project/projectPermissionAlert'; @@ -52,9 +52,11 @@ export default function ProjectReplaySettings() { onSuccess: (response: Project) => ProjectsStore.onUpdateSuccess(response), }; - const {getParamValue, setParamValue} = useUrlParams( + const [tab, setTab] = useQueryState( 'replaySettingsTab', - 'replay-issues' + parseAsStringLiteral(['replay-issues', 'bulk-delete'] as const).withDefault( + 'replay-issues' + ) ); return ( @@ -72,8 +74,8 @@ export default function ProjectReplaySettings() { } /> setParamValue(String(value))} + value={tab} + onChange={value => setTab(value as 'replay-issues' | 'bulk-delete')} > {t('Replay Issues')} diff --git a/static/gsAdmin/components/customers/customerStats.tsx b/static/gsAdmin/components/customers/customerStats.tsx index aa2928ccdd5d9a..ec408a18eb0dcb 100644 --- a/static/gsAdmin/components/customers/customerStats.tsx +++ b/static/gsAdmin/components/customers/customerStats.tsx @@ -22,7 +22,7 @@ import {defined} from 'sentry/utils'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {getDynamicText} from 'sentry/utils/getDynamicText'; import {useApiQuery} from 'sentry/utils/queryClient'; -import {useRouter} from 'sentry/utils/useRouter'; +import {useLocation} from 'sentry/utils/useLocation'; enum SeriesName { ACCEPTED = 'Accepted', @@ -434,7 +434,7 @@ type Props = { export const CustomerStats = memo( ({orgSlug, projectId, dataType, onDemandPeriodStart, onDemandPeriodEnd}: Props) => { - const router = useRouter(); + const location = useLocation(); const dataDatetime = useMemo((): DateTimeObject => { const { @@ -442,7 +442,7 @@ export const CustomerStats = memo( end, utc: utcString, statsPeriod, - } = normalizeDateTimeParams(router.location.query, { + } = normalizeDateTimeParams(location.query, { allowEmptyPeriod: true, allowAbsoluteDatetime: true, allowAbsolutePageDatetime: true, @@ -474,7 +474,7 @@ export const CustomerStats = memo( return { period: statsPeriod ?? '90d', }; - }, [router.location.query, onDemandPeriodStart, onDemandPeriodEnd]); + }, [location.query, onDemandPeriodStart, onDemandPeriodEnd]); const statsEndpointUrl = getApiUrl(`/organizations/$organizationIdOrSlug/stats_v2/`, { path: {organizationIdOrSlug: orgSlug}, diff --git a/static/gsAdmin/components/customers/customerStatsFilters.tsx b/static/gsAdmin/components/customers/customerStatsFilters.tsx index c270f64acbe504..5dc24b0790d65c 100644 --- a/static/gsAdmin/components/customers/customerStatsFilters.tsx +++ b/static/gsAdmin/components/customers/customerStatsFilters.tsx @@ -17,7 +17,8 @@ import { import {DATA_CATEGORY_INFO, DEFAULT_RELATIVE_PERIODS} from 'sentry/constants'; import {DataCategoryExact} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; -import {useRouter} from 'sentry/utils/useRouter'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; const ON_DEMAND_PERIOD_KEY = 'onDemand'; @@ -35,13 +36,12 @@ export function CustomerStatsFilters({ onDemandPeriodStart, onDemandPeriodEnd, }: Props) { - const router = useRouter(); + const location = useLocation(); + const navigate = useNavigate(); const onDemand = !!onDemandPeriodStart && !!onDemandPeriodEnd; const pageDateTime = useMemo((): DateTimeObject => { - const query = router.location.query; - - const {start, end, statsPeriod} = normalizeDateTimeParams(query, { + const {start, end, statsPeriod} = normalizeDateTimeParams(location.query, { allowEmptyPeriod: true, allowAbsoluteDatetime: true, allowAbsolutePageDatetime: true, @@ -59,7 +59,7 @@ export function CustomerStatsFilters({ } return {}; - }, [router.location.query]); + }, [location.query]); const handleDateChange = useCallback( (datetime: ChangeData) => { @@ -68,10 +68,10 @@ export function CustomerStatsFilters({ if (start && end) { const parser = utc ? moment.utc : moment; - router.push({ + navigate({ ...location, query: { - ...router.location.query, + ...location.query, statsPeriod: undefined, start: parser(start).format(), end: parser(end).format(), @@ -81,10 +81,10 @@ export function CustomerStatsFilters({ return; } - router.push({ + navigate({ ...location, query: { - ...router.location.query, + ...location.query, statsPeriod: relative === ON_DEMAND_PERIOD_KEY ? undefined : relative, start: undefined, end: undefined, @@ -92,7 +92,7 @@ export function CustomerStatsFilters({ }, }); }, - [router] + [location, navigate] ); const {start, end, period, utc} = pageDateTime; diff --git a/static/gsAdmin/views/instanceLevelOAuth/instanceLevelOAuthDetails.tsx b/static/gsAdmin/views/instanceLevelOAuth/instanceLevelOAuthDetails.tsx index 0aaa5dd308d4b5..814d0b2e787b4a 100644 --- a/static/gsAdmin/views/instanceLevelOAuth/instanceLevelOAuthDetails.tsx +++ b/static/gsAdmin/views/instanceLevelOAuth/instanceLevelOAuthDetails.tsx @@ -14,7 +14,7 @@ import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse'; import type {RequestError} from 'sentry/utils/requestError/requestError'; import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; import {useApi} from 'sentry/utils/useApi'; -import {useRouter} from 'sentry/utils/useRouter'; +import {useParams} from 'sentry/utils/useParams'; import {PageHeader} from 'admin/components/pageHeader'; @@ -40,7 +40,7 @@ const fieldProps = { export function InstanceLevelOAuthDetails() { const api = useApi(); - const router = useRouter(); + const params = useParams<{clientID: string}>(); const [clientDetails, setClientDetails] = useState(); const [errorMessage, setErrorMessage] = useState(); @@ -49,7 +49,7 @@ export function InstanceLevelOAuthDetails() { const fetchClientData = useCallback(async () => { try { const response = await api.requestPromise( - `/_admin/instance-level-oauth/${router.params.clientID}/`, + `/_admin/instance-level-oauth/${params.clientID}/`, {} ); @@ -72,7 +72,7 @@ export function InstanceLevelOAuthDetails() { } finally { setLoading(false); } - }, [router.params.clientID, api]); + }, [params.clientID, api]); useEffect(() => { fetchClientData(); diff --git a/static/gsApp/components/features/illustrations/alertsBackground.tsx b/static/gsApp/components/features/illustrations/alertsBackground.tsx index 95287961081541..82d4caefdb8753 100644 --- a/static/gsApp/components/features/illustrations/alertsBackground.tsx +++ b/static/gsApp/components/features/illustrations/alertsBackground.tsx @@ -3,8 +3,6 @@ import styled from '@emotion/styled'; import type {Variants} from 'framer-motion'; import {motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - const random = (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min; @@ -13,11 +11,11 @@ const backgroundAnimateIn: Variants = { animate: { opacity: 1, scale: 1, - transition: testableTransition({ + transition: { type: 'spring', damping: 8, stiffness: 60, - }), + }, }, }; @@ -31,7 +29,7 @@ const wormholeAnimateIn: Variants = { opacity: 1, scale: 1, rotate: 0, - transition: testableTransition({delay: 2, duration: 2.5}), + transition: {delay: 2, duration: 2.5}, }, }; @@ -64,7 +62,7 @@ const shipAnimateIn: Variants = { scale: 1, translateX: 0, translateY: 0, - transition: testableTransition({duration: 0.8}), + transition: {duration: 0.8}, }, }; diff --git a/static/gsApp/components/features/illustrations/discoverBackground.tsx b/static/gsApp/components/features/illustrations/discoverBackground.tsx index 1a1cc23b970a74..2963a9a7b41b72 100644 --- a/static/gsApp/components/features/illustrations/discoverBackground.tsx +++ b/static/gsApp/components/features/illustrations/discoverBackground.tsx @@ -1,9 +1,8 @@ import {css, keyframes} from '@emotion/react'; import styled from '@emotion/styled'; +import type {Transition} from 'framer-motion'; import {motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - // Computed using SVGGeometryElement.getTotalLength() const STROKE_LENGTH = 4445; @@ -19,12 +18,12 @@ const strokeAnimation = { init: {strokeDashoffset: STROKE_LENGTH}, animate: {strokeDashoffset: 0}, }, - transition: testableTransition({ + transition: { type: 'tween', duration: 15, delay: 2.5, ease: 'linear', - }), + } satisfies Transition, }; const StrokeBackground = styled(motion.path)``; @@ -33,7 +32,7 @@ const strokeBackgroundAnimation = { init: {opacity: 0}, animate: {opacity: 0.5}, }, - transition: testableTransition({duration: 1}), + transition: {duration: 1}, }; const Dot = styled(motion.g)``; @@ -42,7 +41,6 @@ const dotAnimation = { init: {scale: 0.5, opacity: 0}, animate: {scale: 1, opacity: 1}, }, - transition: testableTransition(), }; const Guy1 = styled(motion.g)``; @@ -51,7 +49,7 @@ const guy1Animation = { init: {opacity: 0, x: 20}, animate: {opacity: 1, x: 0}, }, - transition: testableTransition({bounce: 0.15}), + transition: {bounce: 0.15}, }; const Guy2 = styled(motion.g)``; @@ -60,7 +58,7 @@ const guy2Animation = { init: {opacity: 0, x: -20}, animate: {opacity: 1, x: 0}, }, - transition: testableTransition({bounce: 0.15}), + transition: {bounce: 0.15}, }; const shake = keyframes` @@ -86,11 +84,11 @@ const errorAsteroidAnimation = { init: {opacity: 0, x: 150, y: -150}, animate: {opacity: 1, x: 0, y: 0}, }, - transition: testableTransition({ + transition: { type: 'spring', delay: 1.8, bounce: 0.15, - }), + } satisfies Transition, }; const ploom = keyframes` @@ -129,10 +127,10 @@ const landBeforeTimeAnimation = { init: {opacity: 0, filter: 'saturation(0)'}, animate: {opacity: 1, filter: 'saturation(1)'}, }, - transition: testableTransition({ + transition: { type: 'tween', duration: 1.4, - }), + } satisfies Transition, }; export function DiscoverBackground({anchorRef}: Props) { @@ -140,7 +138,7 @@ export function DiscoverBackground({anchorRef}: Props) { - - - - - - - diff --git a/static/gsApp/components/features/illustrations/performanceBackground.tsx b/static/gsApp/components/features/illustrations/performanceBackground.tsx index 1834275d05d0f2..9421c46087aec0 100644 --- a/static/gsApp/components/features/illustrations/performanceBackground.tsx +++ b/static/gsApp/components/features/illustrations/performanceBackground.tsx @@ -3,8 +3,6 @@ import styled from '@emotion/styled'; import type {Variants} from 'framer-motion'; import {motion} from 'framer-motion'; -import {testableTransition} from 'sentry/utils/testableTransition'; - const random = (min: number, max: number) => Math.floor(Math.random() * (max - min)) + min; @@ -13,11 +11,11 @@ const backgroundAnimateIn: Variants = { animate: { opacity: 1, scale: 1, - transition: testableTransition({ + transition: { type: 'spring', damping: 8, stiffness: 60, - }), + }, }, }; @@ -40,7 +38,10 @@ const Background = styled(motion.g)` const animation: Variants = { initial: {opacity: 0, translateY: -100}, - animate: {opacity: 1, translateY: 0, transition: testableTransition()}, + animate: { + opacity: 1, + translateY: 0, + }, }; const fallingKeyframes = keyframes` diff --git a/static/gsApp/components/primaryNavSeerConfigReminder.tsx b/static/gsApp/components/primaryNavSeerConfigReminder.tsx index 36089416c6e7dd..ba625eec543460 100644 --- a/static/gsApp/components/primaryNavSeerConfigReminder.tsx +++ b/static/gsApp/components/primaryNavSeerConfigReminder.tsx @@ -128,36 +128,24 @@ function useReminderCopywriting() { const hasSeatBasedSeer = organization.features.includes('seat-based-seer-enabled'); const hasLegacySeer = organization.features.includes('seer-added'); - const descriptionByStep: Record< - Steps, - {description: string; pathname: string; title: string} | null - > = { + const descriptionByStep: Record = { [Steps.CONNECT_GITHUB]: { title: t('Connect GitHub'), description: t( 'Seer is enabled, but Github is not connected. Connect your GitHub account to enable Root Cause Analysis and Code Review.' ), - pathname: hasLegacySeer - ? `/settings/${organization.slug}/seer/` - : `/settings/${organization.slug}/seer/onboarding/`, }, [Steps.SETUP_ROOT_CAUSE_ANALYSIS]: { title: t('Start using Seer\u2019s Issue Autofix'), description: t( 'Seer is enabled but Root Cause Analysis is not configured. Configure Seer to automatically look at issues and generate code fixes.' ), - pathname: hasLegacySeer - ? `/settings/${organization.slug}/seer/` - : `/settings/${organization.slug}/seer/onboarding/`, }, [Steps.SETUP_CODE_REVIEW]: { title: t('Start using Seer\u2019s AI Code Review'), description: t( 'Seer is enabled but Code Review is not configured. Configure Seer to automatically review PRs and flag potential issues.' ), - pathname: hasLegacySeer - ? `/settings/${organization.slug}/seer/` - : `/settings/${organization.slug}/seer/onboarding/`, }, [Steps.SETUP_DEFAULTS]: null, [Steps.WRAP_UP]: null, @@ -217,7 +205,7 @@ export function PrimaryNavSeerConfigReminder() { {copy.description} state.close()} analyticsEventName="Seer Config Reminder: Configure Now Clicked" diff --git a/static/gsApp/components/productSelectionAvailability.spec.tsx b/static/gsApp/components/productSelectionAvailability.spec.tsx index b619008b52b58d..d8fc54448b91e3 100644 --- a/static/gsApp/components/productSelectionAvailability.spec.tsx +++ b/static/gsApp/components/productSelectionAvailability.spec.tsx @@ -351,6 +351,7 @@ describe('ProductSelectionAvailability', () => { reservedSeerScanner: undefined, reservedSeerUsers: undefined, reservedSizeAnalyses: 0, + reservedTraceMetricBytes: 0, }; const mockPlan = PlanFixture({}); const mockPreview = PreviewDataFixture({}); diff --git a/static/gsApp/components/productUnavailableCTA.spec.tsx b/static/gsApp/components/productUnavailableCTA.spec.tsx index 35785e0bc587d5..63792a6c5c69cc 100644 --- a/static/gsApp/components/productUnavailableCTA.spec.tsx +++ b/static/gsApp/components/productUnavailableCTA.spec.tsx @@ -219,6 +219,7 @@ describe('ProductUnavailableCTA', () => { reservedSeerScanner: undefined, reservedSeerUsers: undefined, reservedSizeAnalyses: undefined, + reservedTraceMetricBytes: undefined, }; const mockPlan = PlanFixture({}); const mockPreview = PreviewDataFixture({}); diff --git a/static/gsApp/components/upgradeNowModal/usePreviewData.spec.tsx b/static/gsApp/components/upgradeNowModal/usePreviewData.spec.tsx index 2bb679032fc052..c771d6db5222a2 100644 --- a/static/gsApp/components/upgradeNowModal/usePreviewData.spec.tsx +++ b/static/gsApp/components/upgradeNowModal/usePreviewData.spec.tsx @@ -26,6 +26,7 @@ const mockReservations: Reservations = { reservedSeerScanner: 0, reservedSeerUsers: 0, reservedSizeAnalyses: 100, + reservedTraceMetricBytes: undefined, }; const mockPreview = PreviewDataFixture({}); diff --git a/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.spec.tsx b/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.spec.tsx index 828c9378007283..e11edfd1694495 100644 --- a/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.spec.tsx +++ b/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.spec.tsx @@ -59,6 +59,7 @@ describe('useUpgradeNowParams', () => { reservedSeerScanner: 0, reservedSeerUsers: 0, reservedSizeAnalyses: 100, + reservedTraceMetricBytes: undefined, }, }) ); diff --git a/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.tsx b/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.tsx index 1d934da6b77421..d02c2a6de0b12a 100644 --- a/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.tsx +++ b/static/gsApp/components/upgradeNowModal/useUpgradeNowParams.tsx @@ -108,6 +108,7 @@ export function useUpgradeNowParams({organization, subscription, enabled = true} reservedSeerScanner: reserved.seerScanner, reservedSeerUsers: reserved.seerUsers, reservedSizeAnalyses: reserved.sizeAnalyses, + reservedTraceMetricBytes: reserved.traceMetricBytes, }, }; }, [billingConfig, isPending, subscription, enabled]); diff --git a/static/gsApp/components/upsellModal/details.tsx b/static/gsApp/components/upsellModal/details.tsx index 0c0c51ca55521c..45edb02fabc05a 100644 --- a/static/gsApp/components/upsellModal/details.tsx +++ b/static/gsApp/components/upsellModal/details.tsx @@ -15,7 +15,6 @@ import userMiseryImg from 'getsentry-images/features/user-misery.svg'; import {t, tct} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; -import {testableTransition} from 'sentry/utils/testableTransition'; import type {Subscription} from 'getsentry/types'; import {getTrialLength, hasPerformance, isTrialPlan} from 'getsentry/utils/billing'; @@ -476,13 +475,12 @@ const featureContentAnimation = { exit: { opacity: 0, x: 20, - transition: testableTransition(), }, animate: { opacity: 1, x: 0, - transition: testableTransition({ + transition: { delay: 0.02, - }), + }, }, }; diff --git a/static/gsApp/components/upsellModal/featureList.tsx b/static/gsApp/components/upsellModal/featureList.tsx index 3ffebbd12427a0..c4baa331a92004 100644 --- a/static/gsApp/components/upsellModal/featureList.tsx +++ b/static/gsApp/components/upsellModal/featureList.tsx @@ -7,7 +7,6 @@ import {AnimatePresence, motion} from 'framer-motion'; import {ProgressRing} from 'sentry/components/progressRing'; import {IconBusiness} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {testableTransition} from 'sentry/utils/testableTransition'; import {MoreFeaturesLink} from 'getsentry/views/amCheckout/components/moreFeaturesLink'; @@ -51,7 +50,6 @@ export function FeatureList({ aria-selected={feat === selected ? true : undefined} data-test-id={feat.id} whileTap={{x: -7}} - transition={testableTransition()} > {feat.name} diff --git a/static/gsApp/constants.tsx b/static/gsApp/constants.tsx index 57652941d53347..00998ca509ab9a 100644 --- a/static/gsApp/constants.tsx +++ b/static/gsApp/constants.tsx @@ -196,6 +196,13 @@ export const BILLED_DATA_CATEGORY_INFO = { ), shortenedUnitName: 'GB', }, + [DataCategoryExact.TRACE_METRIC_BYTE]: { + ...DEFAULT_BILLED_DATA_CATEGORY_INFO[DataCategoryExact.TRACE_METRIC_BYTE], + canProductTrial: true, + freeEventsMultiple: 1, + feature: 'expose-category-trace-metric-byte', + shortenedUnitName: 'GB', + }, [DataCategoryExact.SEER_USER]: { ...DEFAULT_BILLED_DATA_CATEGORY_INFO[DataCategoryExact.SEER_USER], feature: 'seer-user-billing-launch', diff --git a/static/gsApp/utils/dataCategory.spec.tsx b/static/gsApp/utils/dataCategory.spec.tsx index 7c02a019984b7d..ad5ec07467ee92 100644 --- a/static/gsApp/utils/dataCategory.spec.tsx +++ b/static/gsApp/utils/dataCategory.spec.tsx @@ -416,9 +416,10 @@ describe('listDisplayNames', () => { }); describe('isByteCategory', () => { - it('verifies isByteCategory function handles both ATTACHMENTS and LOG_BYTE', () => { + it('verifies isByteCategory function handles ATTACHMENTS, LOG_BYTE, and TRACE_METRIC_BYTE', () => { expect(isByteCategory(DataCategory.ATTACHMENTS)).toBe(true); expect(isByteCategory(DataCategory.LOG_BYTE)).toBe(true); + expect(isByteCategory(DataCategory.TRACE_METRIC_BYTE)).toBe(true); expect(isByteCategory(DataCategory.ERRORS)).toBe(false); expect(isByteCategory(DataCategory.TRANSACTIONS)).toBe(false); }); diff --git a/static/gsApp/utils/dataCategory.tsx b/static/gsApp/utils/dataCategory.tsx index 61bf1e8bbdd3ef..6884e13ae502d8 100644 --- a/static/gsApp/utils/dataCategory.tsx +++ b/static/gsApp/utils/dataCategory.tsx @@ -246,7 +246,11 @@ export function isContinuousProfiling(category: DataCategory | string) { } export function isByteCategory(category: DataCategory | string) { - return category === DataCategory.ATTACHMENTS || category === DataCategory.LOG_BYTE; + return ( + category === DataCategory.ATTACHMENTS || + category === DataCategory.LOG_BYTE || + category === DataCategory.TRACE_METRIC_BYTE + ); } /** diff --git a/static/gsApp/utils/trackGetsentryAnalytics.tsx b/static/gsApp/utils/trackGetsentryAnalytics.tsx index b378b3a6a90347..90a6923bd7fbd2 100644 --- a/static/gsApp/utils/trackGetsentryAnalytics.tsx +++ b/static/gsApp/utils/trackGetsentryAnalytics.tsx @@ -152,6 +152,8 @@ type GetsentryEventParameters = { 'growth.upsell_feature.cancelled': UpsellProvider; 'growth.upsell_feature.clicked': UpsellProvider; 'growth.upsell_feature.confirmed': UpsellProvider; + 'intercom_link.clicked': {source?: string}; + 'intercom_link.viewed': {source?: string}; 'learn_more_link.clicked': {source?: string}; 'ondemand_budget_modal.ondemand_budget.turned_off': Record; 'ondemand_budget_modal.ondemand_budget.update': OnDemandBudgetUpdate; @@ -368,9 +370,11 @@ const GETSENTRY_EVENT_MAP: Record = { 'upgrade_now.modal.sent_email': 'Upgrade Now Modal: Sent Email', 'upgrade_now.modal.update_now': 'Upgrade Now Modal: Clicked Update Now', 'upgrade_now.modal.viewed': 'Upgrade Now Modal: Viewed Modal', - 'zendesk_link.viewed': 'Zendesk Link Viewed', - 'zendesk_link.clicked': 'Zendesk Link Clicked', + 'intercom_link.clicked': 'Intercom Link Clicked', + 'intercom_link.viewed': 'Intercom Link Viewed', 'learn_more_link.clicked': 'Learn More Link Clicked', + 'zendesk_link.clicked': 'Zendesk Link Clicked', + 'zendesk_link.viewed': 'Zendesk Link Viewed', 'spend_allocations.open_form': 'Spend Allocations: Form Opened', 'spend_allocations.submit': 'Spend Allocations: Form Submitted', 'data_consent_modal.learn_more': 'Data Consent Modal: Learn More', diff --git a/static/gsApp/views/seerAutomation/components/seerSettingsPageContent.tsx b/static/gsApp/views/seerAutomation/components/seerSettingsPageContent.tsx index 23449f9a5e5068..4b42c8ca13e011 100644 --- a/static/gsApp/views/seerAutomation/components/seerSettingsPageContent.tsx +++ b/static/gsApp/views/seerAutomation/components/seerSettingsPageContent.tsx @@ -24,7 +24,7 @@ export function SeerSettingsPageContent({children}: Props) { return ( - + {organization.features.includes('seer-wizard') ? : null} {showNoActiveSeerSubscriptionBanner ? : null} diff --git a/static/gsApp/views/seerAutomation/onboarding/onboarding.tsx b/static/gsApp/views/seerAutomation/onboarding/onboarding.tsx index 87aa0dc5099bc1..024793e2a6077e 100644 --- a/static/gsApp/views/seerAutomation/onboarding/onboarding.tsx +++ b/static/gsApp/views/seerAutomation/onboarding/onboarding.tsx @@ -1,5 +1,7 @@ import {AnalyticsArea} from 'sentry/components/analyticsArea'; +import {Redirect} from 'sentry/components/redirect'; import {showNewSeer} from 'sentry/utils/seer/showNewSeer'; +import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useOrganization} from 'sentry/utils/useOrganization'; import {SeerAutomationOnboarding as SeerOnboardingLegacy} from './onboardingLegacy'; @@ -12,11 +14,14 @@ export default function SeerOnboarding() { const organization = useOrganization(); if (showNewSeer(organization)) { - return ( - - - - ); + if (organization.features.includes('seer-wizard')) { + return ( + + + + ); + } + return ; } return ( diff --git a/tests/acceptance/test_scm_onboarding.py b/tests/acceptance/test_scm_onboarding.py new file mode 100644 index 00000000000000..0f872712336f2d --- /dev/null +++ b/tests/acceptance/test_scm_onboarding.py @@ -0,0 +1,354 @@ +from unittest import mock + +import pytest + +from sentry.api.serializers import serialize +from sentry.integrations.models.integration import Integration +from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.models.project import Project +from sentry.shared_integrations.exceptions import ApiError +from sentry.testutils.asserts import assert_existing_projects_status +from sentry.testutils.cases import AcceptanceTestCase +from sentry.testutils.silo import no_silo_test +from sentry.utils import json + +pytestmark = pytest.mark.sentry_metrics + + +@no_silo_test +class ScmOnboardingTest(AcceptanceTestCase): + def setUp(self) -> None: + super().setUp() + self.user = self.create_user("foo@example.com") + self.org = self.create_organization(name="Rowdy Tiger", owner=None) + self.team = self.create_team(organization=self.org, name="Mariachi Band") + self.member = self.create_member( + user=self.user, organization=self.org, role="owner", teams=[self.team] + ) + self.login_as(self.user) + + def create_github_integration(self) -> Integration: + integration = self.create_provider_integration( + provider="github", + name="getsentry", + external_id="12345", + metadata={"access_token": "ghu_xxxxx"}, + ) + integration.add_organization(self.org, self.user) + return integration + + def start_onboarding(self) -> None: + self.browser.get(f"/onboarding/{self.org.slug}/") + self.browser.wait_until('[data-test-id="onboarding-step-welcome"]') + self.browser.click('[data-test-id="onboarding-welcome-start"]') + self.browser.wait_until('[data-test-id="onboarding-step-scm-connect"]') + + def test_scm_onboarding_happy_path(self) -> None: + """Full flow: welcome → connect repo → detected platform → create project.""" + self.create_github_integration() + + mock_repos = [ + { + "name": "sentry", + "identifier": "getsentry/sentry", + "default_branch": "master", + }, + ] + + mock_platforms = [ + { + "platform": "python-django", + "language": "Python", + "bytes": 50000, + "confidence": "high", + "priority": 1, + } + ] + + with ( + self.feature( + { + "organizations:onboarding-scm": True, + "organizations:integrations-github-platform-detection": True, + } + ), + mock.patch( + "sentry.integrations.github.integration.GitHubIntegration.get_repositories", + return_value=mock_repos, + ), + mock.patch( + "sentry.integrations.github.repository.GitHubRepositoryProvider._validate_repo", + return_value={"id": "12345"}, + ), + mock.patch( + "sentry.integrations.api.endpoints.organization_repository_platforms.detect_platforms", + return_value=mock_platforms, + ), + ): + self.start_onboarding() + + # SCM Connect: wait for integration to be detected, then search + self.browser.wait_until(xpath='//*[contains(text(), "Connected to")]') + # react-select renders a separate placeholder element, not an HTML + # placeholder attribute, so target the input by its ARIA role. + input_el = self.browser.element('input[aria-autocomplete="list"]') + input_el.send_keys("sentry") + self.browser.wait_until('[data-test-id="menu-list-item-label"]') + self.browser.click('[data-test-id="menu-list-item-label"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Continue")]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Platform Features: select detected platform, then continue + self.browser.wait_until('[data-test-id="onboarding-step-scm-platform-features"]') + self.browser.wait_until('[role="radio"]') + self.browser.click('[role="radio"]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Project Details: defaults auto-fill from platform + team + self.browser.wait_until('[data-test-id="onboarding-step-scm-project-details"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Create project")]') + self.browser.click(xpath='//button[contains(., "Create project")]') + + # Setup Docs: verify SDK heading renders, not just the step container + self.browser.wait_until(xpath='//h2[text()="Configure Django SDK"]') + + project = Project.objects.get(organization=self.org) + assert project.platform == "python-django" + assert project.name == "python-django" + assert project.slug == "python-django" + assert_existing_projects_status( + self.org, active_project_ids=[project.id], deleted_project_ids=[] + ) + + def test_scm_onboarding_skip_integration(self) -> None: + """Skip flow: welcome → skip connect → manual platform → create project.""" + with self.feature({"organizations:onboarding-scm": True}): + self.start_onboarding() + + # SCM Connect: skip + self.browser.click(xpath='//button[contains(., "Skip for now")]') + + # Platform Features: manual picker + self.browser.wait_until('[data-test-id="onboarding-step-scm-platform-features"]') + self.browser.wait_until(xpath='//h3[text()="Select a platform"]') + input_el = self.browser.element('input[aria-autocomplete="list"]') + input_el.send_keys("React") + self.browser.wait_until( + xpath='//p[@data-test-id="menu-list-item-label"][text()="React"]' + ) + self.browser.click(xpath='//p[@data-test-id="menu-list-item-label"][text()="React"]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Project Details: defaults auto-fill + self.browser.wait_until('[data-test-id="onboarding-step-scm-project-details"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Create project")]') + self.browser.click(xpath='//button[contains(., "Create project")]') + + # Setup Docs + self.browser.wait_until(xpath='//h2[text()="Configure React SDK"]') + + project = Project.objects.get(organization=self.org) + assert project.platform == "javascript-react" + assert project.name == "javascript-react" + assert project.slug == "javascript-react" + assert_existing_projects_status( + self.org, active_project_ids=[project.id], deleted_project_ids=[] + ) + + def test_scm_onboarding_with_integration_install(self) -> None: + """Install flow: welcome → install GitHub → repo search → detected platform → create project.""" + mock_repos = [ + { + "name": "sentry", + "identifier": "getsentry/sentry", + "default_branch": "master", + }, + ] + + mock_platforms = [ + { + "platform": "python-django", + "language": "Python", + "bytes": 50000, + "confidence": "high", + "priority": 1, + } + ] + + with ( + self.feature( + { + "organizations:onboarding-scm": True, + "organizations:integrations-github-platform-detection": True, + } + ), + mock.patch( + "sentry.integrations.github.integration.GitHubIntegration.get_repositories", + return_value=mock_repos, + ), + mock.patch( + "sentry.integrations.github.repository.GitHubRepositoryProvider._validate_repo", + return_value={"id": "12345"}, + ), + mock.patch( + "sentry.integrations.api.endpoints.organization_repository_platforms.detect_platforms", + return_value=mock_platforms, + ), + ): + self.start_onboarding() + + # SCM Connect: no integration installed, provider pills are shown. + # Override window.open so that AddIntegration stores `window` as the + # dialog reference. When we later inject a postMessage from the same + # window, `message.source === this.dialog` passes. + self.browser.driver.execute_script( + """ + window.__testOpenCalled = false; + window.open = function() { + window.__testOpenCalled = true; + return window; + }; + """ + ) + + # Wait for the providers to load, then click Install GitHub. + self.browser.wait_until(xpath='//button[contains(., "GitHub")]') + self.browser.click(xpath='//button[contains(., "GitHub")]') + assert self.browser.driver.execute_script("return window.__testOpenCalled") + + # Simulate the OAuth pipeline: create the integration in the DB, + # then serialize it with the same code path as IntegrationPipeline._dialog_response + # to avoid mock-drift between the test data and the real serializer. + integration = self.create_github_integration() + org_integration = OrganizationIntegration.objects.get( + integration=integration, organization_id=self.org.id + ) + # Resolve Django lazy objects (translations, datetimes) so + # Selenium can JSON-serialize the data for execute_script. + serialized = json.loads(json.dumps(serialize(org_integration, self.user))) + self.browser.driver.execute_script( + "window.postMessage(arguments[0], window.location.origin);", + {"success": True, "data": serialized}, + ) + + # Wait for the component to process the message and show connected state. + self.browser.wait_until(xpath='//*[contains(text(), "Connected to")]') + + # Repo search (same flow as happy path from here on). + input_el = self.browser.element('input[aria-autocomplete="list"]') + input_el.send_keys("sentry") + self.browser.wait_until('[data-test-id="menu-list-item-label"]') + self.browser.click('[data-test-id="menu-list-item-label"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Continue")]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Platform Features + self.browser.wait_until('[data-test-id="onboarding-step-scm-platform-features"]') + self.browser.wait_until('[role="radio"]') + self.browser.click('[role="radio"]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Project Details + self.browser.wait_until('[data-test-id="onboarding-step-scm-project-details"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Create project")]') + self.browser.click(xpath='//button[contains(., "Create project")]') + + # Setup Docs + self.browser.wait_until(xpath='//h2[text()="Configure Django SDK"]') + + project = Project.objects.get(organization=self.org) + assert project.platform == "python-django" + assert project.name == "python-django" + assert project.slug == "python-django" + assert_existing_projects_status( + self.org, active_project_ids=[project.id], deleted_project_ids=[] + ) + + def test_scm_onboarding_detection_error_falls_back_to_manual_picker(self) -> None: + """When platform detection fails, user can still select a platform manually.""" + self.create_github_integration() + + mock_repos = [ + { + "name": "sentry", + "identifier": "getsentry/sentry", + "default_branch": "master", + }, + ] + + with ( + self.feature( + { + "organizations:onboarding-scm": True, + "organizations:integrations-github-platform-detection": True, + } + ), + mock.patch( + "sentry.integrations.github.integration.GitHubIntegration.get_repositories", + return_value=mock_repos, + ), + mock.patch( + "sentry.integrations.github.repository.GitHubRepositoryProvider._validate_repo", + return_value={"id": "12345"}, + ), + mock.patch( + "sentry.integrations.api.endpoints.organization_repository_platforms.detect_platforms", + side_effect=ApiError("GitHub API error"), + ), + ): + self.start_onboarding() + + # SCM Connect: select a repo + self.browser.wait_until(xpath='//*[contains(text(), "Connected to")]') + input_el = self.browser.element('input[aria-autocomplete="list"]') + input_el.send_keys("sentry") + self.browser.wait_until('[data-test-id="menu-list-item-label"]') + self.browser.click('[data-test-id="menu-list-item-label"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Continue")]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Platform Features: detection failed, should show manual picker + self.browser.wait_until('[data-test-id="onboarding-step-scm-platform-features"]') + self.browser.wait_until(xpath='//h3[text()="Select a platform"]') + input_el = self.browser.element('input[aria-autocomplete="list"]') + input_el.send_keys("React") + self.browser.wait_until( + xpath='//p[@data-test-id="menu-list-item-label"][text()="React"]' + ) + self.browser.click(xpath='//p[@data-test-id="menu-list-item-label"][text()="React"]') + self.browser.click(xpath='//button[contains(., "Continue")]') + + # Project Details + self.browser.wait_until('[data-test-id="onboarding-step-scm-project-details"]') + self.browser.wait_until_clickable(xpath='//button[contains(., "Create project")]') + self.browser.click(xpath='//button[contains(., "Create project")]') + + # Setup Docs + self.browser.wait_until(xpath='//h2[text()="Configure React SDK"]') + + project = Project.objects.get(organization=self.org) + assert project.platform == "javascript-react" + assert project.name == "javascript-react" + assert project.slug == "javascript-react" + assert_existing_projects_status( + self.org, active_project_ids=[project.id], deleted_project_ids=[] + ) + + def test_scm_onboarding_repo_search_no_results(self) -> None: + """Empty search results show a helpful message about permissions.""" + self.create_github_integration() + + with ( + self.feature({"organizations:onboarding-scm": True}), + mock.patch( + "sentry.integrations.github.integration.GitHubIntegration.get_repositories", + return_value=[], + ), + ): + self.start_onboarding() + + # SCM Connect: integration detected, search returns no results + self.browser.wait_until(xpath='//*[contains(text(), "Connected to")]') + input_el = self.browser.element('input[aria-autocomplete="list"]') + input_el.send_keys("nonexistent-repo") + self.browser.wait_until(xpath='//*[contains(text(), "No repositories found")]') diff --git a/tests/js/setup.ts b/tests/js/setup.ts index e1b2144c75ddb4..28140e2047de51 100644 --- a/tests/js/setup.ts +++ b/tests/js/setup.ts @@ -12,6 +12,7 @@ import { import {type ReactElement} from 'react'; import {configure as configureRtl} from '@testing-library/react'; // eslint-disable-line no-restricted-imports +import {MotionGlobalConfig} from 'framer-motion'; import {enableFetchMocks} from 'jest-fetch-mock'; import {ConfigFixture} from 'sentry-fixture/config'; @@ -41,6 +42,12 @@ enableFetchMocks(); // See https://github.com/jsdom/jsdom/issues/1330 SVGElement.prototype.getTotalLength ??= () => 1; +/** + * Skip all framer-motion animations in tests so components render immediately + * without waiting for animation frames or transitions. + */ +MotionGlobalConfig.skipAnimations = true; + /** * React Testing Library configuration to override the default test id attribute * diff --git a/tests/sentry/deletions/test_repository.py b/tests/sentry/deletions/test_repository.py index c07c84f4041446..fd4e100064f807 100644 --- a/tests/sentry/deletions/test_repository.py +++ b/tests/sentry/deletions/test_repository.py @@ -14,6 +14,7 @@ from sentry.models.projectcodeowners import ProjectCodeOwners from sentry.models.pullrequest import CommentType, PullRequest, PullRequestComment from sentry.models.repository import Repository +from sentry.seer.models.project_repository import SeerProjectRepository from sentry.testutils.cases import TransactionTestCase from sentry.testutils.hybrid_cloud import HybridCloudTestMixin @@ -21,6 +22,7 @@ class DeleteRepositoryTest(TransactionTestCase, HybridCloudTestMixin): def test_simple(self) -> None: org = self.create_organization() + project = self.create_project(organization=org) repo = Repository.objects.create( organization_id=org.id, provider="dummy", @@ -63,6 +65,10 @@ def test_simple(self) -> None: created_at=timezone.now(), updated_at=timezone.now(), ) + seer_project_repo = SeerProjectRepository.objects.create( + project=project, + repository=repo, + ) self.ScheduledDeletion.schedule(instance=repo, days=0) @@ -73,6 +79,7 @@ def test_simple(self) -> None: assert not Commit.objects.filter(id=commit.id).exists() assert not PullRequest.objects.filter(id=pull.id).exists() assert not PullRequestComment.objects.filter(id=comment.id).exists() + assert not SeerProjectRepository.objects.filter(id=seer_project_repo.id).exists() assert Commit.objects.filter(id=commit2.id).exists() def test_codeowners(self) -> None: diff --git a/tests/sentry/grouping/test_parameterization.py b/tests/sentry/grouping/test_parameterization.py index 14a5f576ddf740..48cfb97bc31fc5 100644 --- a/tests/sentry/grouping/test_parameterization.py +++ b/tests/sentry/grouping/test_parameterization.py @@ -708,3 +708,12 @@ def test_replacement_callback_false_positive_triggers_individual_regex_fallback( ) == 0 ) + + # We also only counted the false positive once, even though we hit it both during the main + # combo-regex parameterization and during fallback + assert ( + count_matching_calls( + mock_metrics_incr, "grouping.parameterization_false_positive", tags={"key": "ip"} + ) + == 1 + ) diff --git a/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py b/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py new file mode 100644 index 00000000000000..9f63922c72d299 --- /dev/null +++ b/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py @@ -0,0 +1,205 @@ +from unittest.mock import MagicMock, patch + +from sentry.constants import ObjectStatus +from sentry.integrations.github.integration import GitHubIntegrationProvider +from sentry.integrations.github.tasks.sync_repos_on_install_change import ( + sync_repos_on_install_change, +) +from sentry.models.repository import Repository +from sentry.silo.base import SiloMode +from sentry.testutils.cases import IntegrationTestCase +from sentry.testutils.silo import assume_test_silo_mode, control_silo_test + +FEATURE_FLAG = "organizations:github-repo-auto-sync" + + +@control_silo_test +@patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") +class SyncReposOnInstallChangeTestCase(IntegrationTestCase): + provider = GitHubIntegrationProvider + base_url = "https://api.github.com" + key = "github" + + def _make_repos_added(self): + return [ + {"id": 1, "full_name": "getsentry/sentry", "private": False}, + {"id": 2, "full_name": "getsentry/snuba", "private": False}, + ] + + def _make_repos_removed(self): + return [ + {"id": 3, "full_name": "getsentry/old-repo", "private": False}, + ] + + def test_repos_added(self, _: MagicMock) -> None: + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repos = Repository.objects.filter(organization_id=self.organization.id).order_by("name") + + assert len(repos) == 2 + assert repos[0].name == "getsentry/sentry" + assert repos[0].provider == "integrations:github" + assert repos[0].integration_id == self.integration.id + assert repos[1].name == "getsentry/snuba" + + def test_repos_removed(self, _: MagicMock) -> None: + with assume_test_silo_mode(SiloMode.CELL): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="3", + provider="integrations:github", + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="removed", + repos_added=[], + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED + + def test_mixed_add_and_remove(self, _: MagicMock) -> None: + with assume_test_silo_mode(SiloMode.CELL): + old_repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="3", + provider="integrations:github", + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + old_repo.refresh_from_db() + assert old_repo.status == ObjectStatus.DISABLED + + active_repos = Repository.objects.filter( + organization_id=self.organization.id, + status=ObjectStatus.ACTIVE, + ).order_by("name") + assert len(active_repos) == 2 + assert active_repos[0].name == "getsentry/sentry" + assert active_repos[1].name == "getsentry/snuba" + + def test_multi_org(self, _: MagicMock) -> None: + other_org = self.create_organization(owner=self.user) + self.create_organization_integration( + organization_id=other_org.id, + integration=self.integration, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repos_org1 = Repository.objects.filter(organization_id=self.organization.id) + repos_org2 = Repository.objects.filter(organization_id=other_org.id) + + assert len(repos_org1) == 2 + assert len(repos_org2) == 2 + + def test_missing_integration(self, _: MagicMock) -> None: + sync_repos_on_install_change( + integration_id=0, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_inactive_integration(self, _: MagicMock) -> None: + self.integration.update(status=ObjectStatus.DISABLED) + + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_feature_flag_off(self, _: MagicMock) -> None: + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_empty_repos_is_noop(self, _: MagicMock) -> None: + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=[], + repos_removed=[], + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + assert Repository.objects.count() == 0 + + def test_does_not_disable_already_disabled_repos(self, _: MagicMock) -> None: + with assume_test_silo_mode(SiloMode.CELL): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="3", + provider="integrations:github", + integration_id=self.integration.id, + status=ObjectStatus.DISABLED, + ) + + with self.feature(FEATURE_FLAG): + sync_repos_on_install_change( + integration_id=self.integration.id, + action="removed", + repos_added=[], + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) + + with assume_test_silo_mode(SiloMode.CELL): + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED diff --git a/tests/sentry/integrations/github/test_webhooks.py b/tests/sentry/integrations/github/test_webhook.py similarity index 82% rename from tests/sentry/integrations/github/test_webhooks.py rename to tests/sentry/integrations/github/test_webhook.py index bd3b637b473acd..843b76e2e4dd34 100644 --- a/tests/sentry/integrations/github/test_webhooks.py +++ b/tests/sentry/integrations/github/test_webhook.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +from typing import cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -19,7 +20,11 @@ ) from sentry import options from sentry.constants import ObjectStatus -from sentry.integrations.github.webhook import GitHubIntegrationsWebhookEndpoint +from sentry.integrations.github.webhook import ( + GitHubIntegrationsWebhookEndpoint, + InstallationRepositoriesEventWebhook, +) +from sentry.integrations.github.webhook_types import InstallationRepositoriesEvent from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.services.integration import integration_service @@ -363,6 +368,297 @@ def test_installation_deleted_skips_codecov_unlink_when_app_ids_dont_match( mock_codecov_unlink.assert_not_called() +@control_silo_test +class InstallationRepositoriesEventWebhookTest(APITestCase): + def setUp(self) -> None: + self.url = "/extensions/github/webhook/" + self.secret = "b3002c3e321d4b7880360d397db2ccfd" + options.set("github-app.webhook-secret", self.secret) + + def _make_event(self, action="added", repos_added=None, repos_removed=None): + return json.dumps( + { + "action": action, + "installation": {"id": 2}, + "repositories_added": repos_added or [], + "repositories_removed": repos_removed or [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + def _compute_signatures(self, body: str) -> tuple[str, str]: + sha1 = GitHubIntegrationsWebhookEndpoint.compute_signature( + "sha1", body.encode(), self.secret + ) + sha256 = GitHubIntegrationsWebhookEndpoint.compute_signature( + "sha256", body.encode(), self.secret + ) + return f"sha1={sha1}", f"sha256={sha256}" + + @patch("sentry.integrations.github.webhook.InstallationRepositoriesEventWebhook.__call__") + def test_webhook_dispatches_to_handler(self, mock_call: MagicMock) -> None: + """Verify the endpoint routes installation_repositories events to the correct handler.""" + body = self._make_event( + repos_added=[{"id": 1, "full_name": "getsentry/sentry", "private": False}], + ) + sha1, sha256 = self._compute_signatures(body) + + response = self.client.post( + path=self.url, + data=body, + content_type="application/json", + HTTP_X_GITHUB_EVENT="installation_repositories", + HTTP_X_HUB_SIGNATURE=sha1, + HTTP_X_HUB_SIGNATURE_256=sha256, + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + assert response.status_code == 204 + assert mock_call.called + + def test_end_to_end_repos_added(self) -> None: + """Full end-to-end: webhook URL → handler → task → Repository rows created.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + body = self._make_event( + repos_added=[ + {"id": 10, "full_name": "getsentry/sentry", "private": False}, + {"id": 20, "full_name": "getsentry/snuba", "private": False}, + ], + ) + sha1, sha256 = self._compute_signatures(body) + + with self.feature("organizations:github-repo-auto-sync"), self.tasks(): + response = self.client.post( + path=self.url, + data=body, + content_type="application/json", + HTTP_X_GITHUB_EVENT="installation_repositories", + HTTP_X_HUB_SIGNATURE=sha1, + HTTP_X_HUB_SIGNATURE_256=sha256, + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + assert response.status_code == 204 + + with assume_test_silo_mode(SiloMode.CELL): + repos = Repository.objects.filter(organization_id=self.organization.id).order_by("name") + + assert len(repos) == 2 + assert repos[0].name == "getsentry/sentry" + assert repos[0].provider == "integrations:github" + assert repos[1].name == "getsentry/snuba" + + def test_end_to_end_repos_removed(self) -> None: + """Full end-to-end: webhook URL → handler → task → Repository disabled.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + integration = self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + with assume_test_silo_mode(SiloMode.CELL): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/old-repo", + external_id="30", + provider="integrations:github", + integration_id=integration.id, + status=ObjectStatus.ACTIVE, + ) + + body = self._make_event( + action="removed", + repos_removed=[{"id": 30, "full_name": "getsentry/old-repo", "private": False}], + ) + sha1, sha256 = self._compute_signatures(body) + + with self.feature("organizations:github-repo-auto-sync"), self.tasks(): + response = self.client.post( + path=self.url, + data=body, + content_type="application/json", + HTTP_X_GITHUB_EVENT="installation_repositories", + HTTP_X_HUB_SIGNATURE=sha1, + HTTP_X_HUB_SIGNATURE_256=sha256, + HTTP_X_GITHUB_DELIVERY=str(uuid4()), + ) + assert response.status_code == 204 + + with assume_test_silo_mode(SiloMode.CELL): + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_dispatches_task_on_repos_added(self, mock_apply_async: MagicMock) -> None: + """Test the handler class directly — repos_added dispatches the async task.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + integration = self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "added", + "repositories_added": [ + {"id": 10, "full_name": "getsentry/sentry", "private": False} + ], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["integration_id"] == integration.id + assert kwargs["action"] == "added" + assert len(kwargs["repos_added"]) == 1 + assert kwargs["repos_added"][0]["id"] == 10 + assert kwargs["repos_removed"] == [] + assert kwargs["repository_selection"] == "selected" + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_dispatches_task_on_repos_removed(self, mock_apply_async: MagicMock) -> None: + """Test the handler class directly — repos_removed dispatches the async task.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "removed", + "repositories_added": [], + "repositories_removed": [ + {"id": 20, "full_name": "getsentry/old-repo", "private": False} + ], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_called_once() + kwargs = mock_apply_async.call_args[1]["kwargs"] + assert kwargs["action"] == "removed" + assert len(kwargs["repos_removed"]) == 1 + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_skips_when_no_repos(self, mock_apply_async: MagicMock) -> None: + """No repos added or removed — task should not be dispatched.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "added", + "repositories_added": [], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_not_called() + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_skips_when_malformed_event(self, mock_apply_async: MagicMock) -> None: + """Malformed event missing required keys — handler returns early.""" + handler = InstallationRepositoriesEventWebhook() + malformed_event = cast( + InstallationRepositoriesEvent, + {"repositories_added": [{"id": 1}], "repositories_removed": []}, + ) + handler(event=malformed_event) + + mock_apply_async.assert_not_called() + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_skips_when_integration_not_found(self, mock_apply_async: MagicMock) -> None: + """Integration doesn't exist in Sentry — handler returns early.""" + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 99999}, + "action": "added", + "repositories_added": [{"id": 1, "full_name": "org/repo", "private": False}], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + } + ) + + mock_apply_async.assert_not_called() + + @patch( + "sentry.integrations.github.tasks.sync_repos_on_install_change.sync_repos_on_install_change.apply_async" + ) + def test_handler_propagates_host_for_ghe(self, mock_apply_async: MagicMock) -> None: + """GitHub Enterprise uses host prefix for external_id.""" + future_expires = datetime.now().replace(microsecond=0) + timedelta(minutes=5) + self.create_integration( + name="octocat", + organization=self.organization, + external_id="github.mycompany.com:2", + provider="github", + metadata={"access_token": "1234", "expires_at": future_expires.isoformat()}, + ) + + handler = InstallationRepositoriesEventWebhook() + handler( + event={ + "installation": {"id": 2}, + "action": "added", + "repositories_added": [{"id": 1, "full_name": "org/repo", "private": False}], + "repositories_removed": [], + "repository_selection": "selected", + "sender": {"id": 1, "login": "octocat"}, + }, + host="github.mycompany.com", + ) + + mock_apply_async.assert_called_once() + + class PushEventWebhookTest(APITestCase): def setUp(self) -> None: self.url = "/extensions/github/webhook/" diff --git a/tests/sentry/integrations/services/repository/test_impl.py b/tests/sentry/integrations/services/repository/test_impl.py new file mode 100644 index 00000000000000..a92df36cc47066 --- /dev/null +++ b/tests/sentry/integrations/services/repository/test_impl.py @@ -0,0 +1,162 @@ +from sentry.constants import ObjectStatus +from sentry.integrations.services.repository.service import repository_service +from sentry.models.repository import Repository +from sentry.testutils.cases import TestCase +from sentry.testutils.silo import cell_silo_test + + +@cell_silo_test +class DisableRepositoriesByExternalIdsTest(TestCase): + def setUp(self) -> None: + self.integration = self.create_integration( + organization=self.organization, + external_id="1", + provider="github", + ) + self.provider = "integrations:github" + + def test_disables_matching_active_repos(self) -> None: + repo1 = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + repo2 = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/snuba", + external_id="200", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100", "200"], + ) + + repo1.refresh_from_db() + repo2.refresh_from_db() + assert repo1.status == ObjectStatus.DISABLED + assert repo2.status == ObjectStatus.DISABLED + + def test_does_not_disable_already_disabled_repos(self) -> None: + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.DISABLED, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.DISABLED + + def test_does_not_affect_repos_from_other_integrations(self) -> None: + other_integration = self.create_integration( + organization=self.organization, + external_id="2", + provider="github", + ) + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=other_integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.ACTIVE + + def test_does_not_affect_repos_from_other_orgs(self) -> None: + other_org = self.create_organization() + repo = Repository.objects.create( + organization_id=other_org.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.ACTIVE + + def test_only_disables_specified_external_ids(self) -> None: + repo_to_disable = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + repo_to_keep = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/snuba", + external_id="200", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=["100"], + ) + + repo_to_disable.refresh_from_db() + repo_to_keep.refresh_from_db() + assert repo_to_disable.status == ObjectStatus.DISABLED + assert repo_to_keep.status == ObjectStatus.ACTIVE + + def test_empty_external_ids_is_noop(self) -> None: + repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + external_id="100", + provider=self.provider, + integration_id=self.integration.id, + status=ObjectStatus.ACTIVE, + ) + + repository_service.disable_repositories_by_external_ids( + organization_id=self.organization.id, + integration_id=self.integration.id, + provider=self.provider, + external_ids=[], + ) + + repo.refresh_from_db() + assert repo.status == ObjectStatus.ACTIVE diff --git a/tests/sentry/middleware/integrations/parsers/test_github.py b/tests/sentry/middleware/integrations/parsers/test_github.py index 8abbfcd5de5563..eb2f7f5c470edb 100644 --- a/tests/sentry/middleware/integrations/parsers/test_github.py +++ b/tests/sentry/middleware/integrations/parsers/test_github.py @@ -139,6 +139,52 @@ def test_get_integration_from_request(self) -> None: result = parser.get_integration_from_request() assert result == integration + @override_settings(SILO_MODE=SiloMode.CONTROL) + @override_cells(cell_config) + def test_installation_repositories_routes_to_control_silo(self) -> None: + request = self.factory.post( + self.path, + data={ + "installation": {"id": "1"}, + "repositories_added": [], + "repositories_removed": [], + }, + content_type="application/json", + headers={ + "X-GITHUB-EVENT": GithubWebhookType.INSTALLATION_REPOSITORIES.value, + }, + ) + parser = GithubRequestParser(request=request, response_handler=self.get_response) + assert parser.should_route_to_control_silo(parsed_event={}, request=request) + + @override_settings(SILO_MODE=SiloMode.CONTROL) + @override_cells(cell_config) + def test_installation_routes_to_control_silo(self) -> None: + request = self.factory.post( + self.path, + data={"installation": {"id": "1"}}, + content_type="application/json", + headers={ + "X-GITHUB-EVENT": GithubWebhookType.INSTALLATION.value, + }, + ) + parser = GithubRequestParser(request=request, response_handler=self.get_response) + assert parser.should_route_to_control_silo(parsed_event={}, request=request) + + @override_settings(SILO_MODE=SiloMode.CONTROL) + @override_cells(cell_config) + def test_push_does_not_route_to_control_silo(self) -> None: + request = self.factory.post( + self.path, + data={"installation": {"id": "1"}}, + content_type="application/json", + headers={ + "X-GITHUB-EVENT": GithubWebhookType.PUSH.value, + }, + ) + parser = GithubRequestParser(request=request, response_handler=self.get_response) + assert not parser.should_route_to_control_silo(parsed_event={}, request=request) + @override_settings(SILO_MODE=SiloMode.CONTROL) @override_cells(cell_config) def test_webhook_outbox_creation(self) -> None: diff --git a/tests/sentry/seer/explorer/test_tools.py b/tests/sentry/seer/explorer/test_tools.py index 0b87b5ae009c43..0639121baff067 100644 --- a/tests/sentry/seer/explorer/test_tools.py +++ b/tests/sentry/seer/explorer/test_tools.py @@ -760,11 +760,10 @@ def test_get_trace_waterfall_sliding_window_beyond_limit(self) -> None: assert result is None def test_get_trace_waterfall_includes_status_code(self) -> None: - """Test that span.status_code is included in additional_attributes.""" + """Test that span.status_code is included if additional_attributes is not provided""" transaction_name = "api/test/status" trace_id = uuid.uuid4().hex - # Create a span with status_code span = self.create_span( { "description": "http-request", @@ -782,11 +781,38 @@ def test_get_trace_waterfall_includes_status_code(self) -> None: result = get_trace_waterfall(trace_id, self.organization.id) assert isinstance(result, EAPTrace) - # Find the span and verify additional_attributes contains status_code root_span = result.trace[0] assert "additional_attributes" in root_span assert root_span["additional_attributes"].get("span.status_code") == "500" + def test_get_trace_waterfall_includes_additional_attributes(self) -> None: + """Test that additional_attributes passed into the function are included on returned traces""" + transaction_name = "api/test/status" + trace_id = uuid.uuid4().hex + + span = self.create_span( + { + "description": "http-request", + "sentry_tags": { + "transaction": transaction_name, + "status_code": "500", + "request.url": "best-url-ev3r.biz", + }, + "trace_id": trace_id, + "is_segment": True, + }, + start_ts=self.ten_mins_ago, + ) + self.store_spans([span]) + + result = get_trace_waterfall(trace_id, self.organization.id, ["request.url"]) + assert isinstance(result, EAPTrace) + + root_span = result.trace[0] + assert "additional_attributes" in root_span + assert root_span["additional_attributes"].get("span.status_code") is None + assert root_span["additional_attributes"].get("request.url") == "best-url-ev3r.biz" + class TestTraceTableQuery(APITransactionTestCase, SnubaTestCase, SpanTestCase): def setUp(self) -> None: diff --git a/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py new file mode 100644 index 00000000000000..7f239d45207ef3 --- /dev/null +++ b/tests/sentry/seer/supergroups/endpoints/test_organization_supergroups_by_group.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import orjson + +from sentry.models.group import GroupStatus +from sentry.testutils.cases import APITestCase + + +def mock_seer_response(data): + response = MagicMock() + response.status = 200 + response.data = orjson.dumps(data) + return response + + +class OrganizationSupergroupsByGroupEndpointTest(APITestCase): + endpoint = "sentry-api-0-organization-supergroups-by-group" + + def setUp(self): + super().setUp() + self.login_as(self.user) + self.unresolved_group = self.create_group( + project=self.project, status=GroupStatus.UNRESOLVED + ) + self.resolved_group = self.create_group(project=self.project, status=GroupStatus.RESOLVED) + + @patch( + "sentry.seer.supergroups.endpoints.organization_supergroups_by_group.make_supergroups_get_by_group_ids_request" + ) + def test_status_filter(self, mock_seer): + mock_seer.return_value = mock_seer_response({"supergroups": []}) + + with self.feature("organizations:top-issues-ui"): + self.get_success_response( + self.organization.slug, + group_id=[self.unresolved_group.id, self.resolved_group.id], + status="unresolved", + ) + + body = mock_seer.call_args[0][0] + assert body["group_ids"] == [self.unresolved_group.id] + + def test_status_filter_invalid(self): + with self.feature("organizations:top-issues-ui"): + self.get_error_response( + self.organization.slug, + group_id=[self.unresolved_group.id], + status="bogus", + status_code=400, + ) + + def test_status_filter_all_filtered_out(self): + with self.feature("organizations:top-issues-ui"): + self.get_error_response( + self.organization.slug, + group_id=[self.resolved_group.id], + status="unresolved", + status_code=404, + ) diff --git a/tests/snuba/search/test_backend.py b/tests/snuba/search/test_backend.py index f2092c6be6699c..ba982e5b71a5f9 100644 --- a/tests/snuba/search/test_backend.py +++ b/tests/snuba/search/test_backend.py @@ -14,6 +14,7 @@ NoiseConfig, PerformanceNPlusOneGroupType, PerformanceRenderBlockingAssetSpanGroupType, + ProfileFileIOGroupType, ) from sentry.issues.ingest import send_issue_occurrence_to_eventstream from sentry.issues.issue_search import convert_query_values, issue_search_config, parse_search_query @@ -3868,3 +3869,283 @@ def test_negated_long_message_search(self) -> None: # Negated search for the keyword should NOT return this group results = self.make_query(search_filter_query="!message:excludethis999") assert group_info.group not in set(results) + + +class EventsRecommendedSortTest(TestCase, SharedSnubaMixin, OccurrenceTestMixin): + @property + def backend(self): + return EventsDatasetSnubaSearchBackend() + + def test_recommended_sort_recency(self) -> None: + new_project = self.create_project(organization=self.project.organization) + base_datetime = before_now(hours=1) + + recent_event = self.store_event( + data={ + "fingerprint": ["recent-group"], + "event_id": "a" * 32, + "message": "recent issue", + "timestamp": base_datetime.isoformat(), + "level": "error", + "tags": {"sentry:user": "user1@example.com"}, + }, + project_id=new_project.id, + ) + old_event = self.store_event( + data={ + "fingerprint": ["old-group"], + "event_id": "b" * 32, + "message": "old issue", + "timestamp": (base_datetime - timedelta(days=5)).isoformat(), + "level": "info", + "tags": {"sentry:user": "user2@example.com"}, + }, + project_id=new_project.id, + ) + recent_group = Group.objects.get(id=recent_event.group.id) + old_group = Group.objects.get(id=old_event.group.id) + + results = self.make_query(sort_by="recommended", projects=[new_project]) + assert list(results) == [recent_group, old_group] + + def test_recommended_sort_severity(self) -> None: + base_datetime = before_now(hours=1) + + fatal_event = self.store_event( + data={ + "fingerprint": ["fatal-group"], + "event_id": "c" * 32, + "message": "fatal issue", + "timestamp": (base_datetime - timedelta(minutes=30)).isoformat(), + "level": "fatal", + "tags": {"sentry:user": "user1@example.com"}, + }, + project_id=self.project.id, + ) + info_event = self.store_event( + data={ + "fingerprint": ["info-group"], + "event_id": "d" * 32, + "message": "info issue", + "timestamp": base_datetime.isoformat(), + "level": "info", + "tags": {"sentry:user": "user2@example.com"}, + }, + project_id=self.project.id, + ) + fatal_group = Group.objects.get(id=fatal_event.group.id) + info_group = Group.objects.get(id=info_event.group.id) + + query_executor = self.backend._get_query_executor() + results = query_executor.snuba_search( + start=None, + end=None, + project_ids=[self.project.id], + environment_ids=[], + sort_field="recommended", + organization=self.organization, + group_ids=[fatal_group.id, info_group.id], + limit=150, + referrer=Referrer.TESTING_TEST, + )[0] + scores = {gid: score for gid, score in results} + # Fatal event should score higher despite being slightly older + assert scores[fatal_group.id] > scores[info_group.id] + + def test_recommended_user_impact(self) -> None: + base_datetime = before_now(hours=1) + + # Issue affecting many users + for i in range(10): + self.store_event( + data={ + "fingerprint": ["many-users-group"], + "event_id": f"a{i:031d}", + "message": "many users", + "timestamp": base_datetime.isoformat(), + "level": "error", + "tags": {"sentry:user": f"user{i}@example.com"}, + }, + project_id=self.project.id, + ) + many_users_group = Group.objects.get( + project=self.project, + message="many users", + ) + + # Issue affecting one user + self.store_event( + data={ + "fingerprint": ["one-user-group"], + "event_id": "b" * 32, + "message": "one user", + "timestamp": base_datetime.isoformat(), + "level": "error", + "tags": {"sentry:user": "solo@example.com"}, + }, + project_id=self.project.id, + ) + one_user_group = Group.objects.get( + project=self.project, + message="one user", + ) + + query_executor = self.backend._get_query_executor() + results = query_executor.snuba_search( + start=None, + end=None, + project_ids=[self.project.id], + environment_ids=[], + sort_field="recommended", + organization=self.organization, + group_ids=[many_users_group.id, one_user_group.id], + limit=150, + referrer=Referrer.TESTING_TEST, + )[0] + scores = {gid: score for gid, score in results} + assert scores[many_users_group.id] > scores[one_user_group.id] + + def test_recommended_issue_platform(self) -> None: + base_datetime = before_now(hours=1) + + error_event = self.store_event( + data={ + "fingerprint": ["error-group"], + "event_id": "a" * 32, + "timestamp": base_datetime.isoformat(), + "message": "error event", + "level": "error", + "stacktrace": {"frames": [{"module": "group1"}]}, + }, + project_id=self.project.id, + ) + error_group = error_event.group + + profile_event_id = uuid.uuid4().hex + _, group_info = self.process_occurrence( + event_id=profile_event_id, + project_id=self.project.id, + event_data={ + "title": "some problem", + "platform": "python", + "tags": {"my_tag": "1"}, + "timestamp": before_now(minutes=1).isoformat(), + "received": before_now(minutes=1).isoformat(), + }, + ) + assert group_info is not None + profile_group = group_info.group + + query_executor = self.backend._get_query_executor() + results = query_executor.snuba_search( + start=None, + end=None, + project_ids=[self.project.id], + environment_ids=[], + sort_field="recommended", + organization=self.organization, + group_ids=[error_group.id, profile_group.id], + limit=150, + referrer=Referrer.TESTING_TEST, + )[0] + # Both groups should be returned with valid scores + returned_group_ids = {gid for gid, _ in results} + assert error_group.id in returned_group_ids + assert profile_group.id in returned_group_ids + + def test_recommended_event_volume(self) -> None: + base_datetime = before_now(hours=1) + + # Store 5 events for the high-volume group + for i in range(5): + self.store_event( + data={ + "fingerprint": ["high-volume-group"], + "event_id": f"{'a' * 31}{i}", + "message": "high volume", + "timestamp": base_datetime.isoformat(), + "level": "error", + }, + project_id=self.project.id, + ) + + # Store 1 event for the low-volume group + self.store_event( + data={ + "fingerprint": ["low-volume-group"], + "event_id": "b" * 32, + "message": "low volume", + "timestamp": base_datetime.isoformat(), + "level": "error", + }, + project_id=self.project.id, + ) + + high_volume_group = Group.objects.get(project=self.project, message="high volume") + low_volume_group = Group.objects.get(project=self.project, message="low volume") + + query_executor = self.backend._get_query_executor() + results = query_executor.snuba_search( + start=None, + end=None, + project_ids=[self.project.id], + environment_ids=[], + sort_field="recommended", + organization=self.organization, + group_ids=[high_volume_group.id, low_volume_group.id], + limit=150, + referrer=Referrer.TESTING_TEST, + )[0] + + scores = {group_id: score for group_id, score in results} + assert scores[high_volume_group.id] > scores[low_volume_group.id] + + def test_recommended_group_type_boost(self) -> None: + base_datetime = before_now(hours=1) + + error_event = self.store_event( + data={ + "fingerprint": ["error-group"], + "event_id": "a" * 32, + "timestamp": base_datetime.isoformat(), + "message": "error event", + "level": "error", + "stacktrace": {"frames": [{"module": "group1"}]}, + }, + project_id=self.project.id, + ) + error_group = error_event.group + + _, group_info = self.process_occurrence( + event_id=uuid.uuid4().hex, + project_id=self.project.id, + event_data={ + "title": "some problem", + "platform": "python", + "tags": {"my_tag": "1"}, + "timestamp": base_datetime.isoformat(), + "received": base_datetime.isoformat(), + }, + type=ProfileFileIOGroupType.type_id, + ) + assert group_info is not None + profile_group = group_info.group + + # Boost ProfileFileIOGroupType so it outranks the error group + boost = {ProfileFileIOGroupType.type_id: 0.5} + with self.options({"snuba.search.recommended.group-type-boost": boost}): + query_executor = self.backend._get_query_executor() + results = query_executor.snuba_search( + start=None, + end=None, + project_ids=[self.project.id], + environment_ids=[], + sort_field="recommended", + organization=self.organization, + group_ids=[error_group.id, profile_group.id], + limit=150, + referrer=Referrer.TESTING_TEST, + )[0] + + scores = {gid: score for gid, score in results} + assert scores[profile_group.id] > scores[error_group.id] diff --git a/tests/tools/test_compute_selected_tests.py b/tests/tools/test_compute_selected_tests.py deleted file mode 100644 index 9910662c6118e4..00000000000000 --- a/tests/tools/test_compute_selected_tests.py +++ /dev/null @@ -1,144 +0,0 @@ -from __future__ import annotations - -import runpy -import sqlite3 -from pathlib import Path - -import pytest - -_mod = runpy.run_path( - str( - Path(__file__).resolve().parents[2] - / ".github/workflows/scripts/selective-testing/compute-selected-tests.py" - ) -) -should_run_full_suite = _mod["should_run_full_suite"] -get_changed_test_files = _mod["get_changed_test_files"] -get_affected_test_files = _mod["get_affected_test_files"] - - -def test_full_suite_triggered(): - trigger_files = [ - "sentry/testutils/pytest/sentry.py", - "src/sentry/testutils/pytest/sentry.py", - "pyproject.toml", - "Makefile", - "sentry/conf/server.py", - "src/sentry/conf/server.py", - "sentry/web/urls.py", - "src/sentry/migrations/0001_initial.py", - "src/sentry/replays/migrations/0042_add_index.py", - "src/sentry/issues/migrations/9999_something.py", - ] - for path in trigger_files: - assert should_run_full_suite([path]) is True, path - - -def test_full_suite_not_triggered(): - safe_files = [ - "src/sentry/migrations/initial.py", - "src/sentry/utils/migrations_helper.py", - "src/sentry/migrations/0001_initial.txt", - "src/sentry/models/group.py", - "src/sentry/api/endpoints/project.py", - ] - for path in safe_files: - assert should_run_full_suite([path]) is False, path - assert should_run_full_suite([]) is False - assert ( - should_run_full_suite(["src/sentry/api/foo.py", "src/sentry/migrations/0500_bar.py"]) - is True - ) - - -def test_get_changed_test_files(): - changed = [ - "tests/sentry/api/test_base.py", - "src/sentry/api/base.py", - "tests/sentry/models/test_group.py", - "tests/tools/test_compute_selected_tests.py", - "tests/acceptance/test_foo.py", - "README.md", - ] - assert get_changed_test_files(changed) == { - "tests/sentry/api/test_base.py", - "tests/sentry/models/test_group.py", - } - - -def test_get_changed_test_files_empty(): - assert get_changed_test_files([]) == set() - - -def test_get_affected_test_files(tmp_path): - db_path = tmp_path / ".coverage.combined" - _create_coverage_db( - db_path, - { - "src/sentry/models/group.py": [ - "tests/sentry/models/test_group.py::TestGroup::test_get|run", - "tests/sentry/api/test_issues.py::TestIssues::test_list|run", - ], - "src/sentry/models/project.py": [ - "tests/sentry/models/test_project.py::TestProject::test_create|run", - ], - }, - ) - - assert get_affected_test_files(str(db_path), ["src/sentry/models/group.py"]) == { - "tests/sentry/models/test_group.py", - "tests/sentry/api/test_issues.py", - } - - -def test_get_affected_test_files_no_match(tmp_path): - db_path = tmp_path / ".coverage.combined" - _create_coverage_db( - db_path, - { - "src/sentry/models/group.py": [ - "tests/sentry/models/test_group.py::TestGroup::test_get|run" - ] - }, - ) - - assert get_affected_test_files(str(db_path), ["src/sentry/unrelated.py"]) == set() - - -def test_get_affected_test_files_missing_tables(tmp_path): - db_path = tmp_path / ".coverage.combined" - conn = sqlite3.connect(str(db_path)) - conn.execute("CREATE TABLE file (id INTEGER PRIMARY KEY, path TEXT)") - conn.commit() - conn.close() - - with pytest.raises(ValueError, match="missing line_bits/context tables"): - get_affected_test_files(str(db_path), ["src/sentry/models/group.py"]) - - -def _create_coverage_db(db_path, file_contexts): - conn = sqlite3.connect(str(db_path)) - cur = conn.cursor() - cur.execute("CREATE TABLE file (id INTEGER PRIMARY KEY, path TEXT)") - cur.execute("CREATE TABLE context (id INTEGER PRIMARY KEY, context TEXT)") - cur.execute("CREATE TABLE line_bits (file_id INTEGER, context_id INTEGER, numbits BLOB)") - - file_id = 0 - ctx_id = 0 - seen_contexts: dict[str, int] = {} - - for file_path, contexts in file_contexts.items(): - file_id += 1 - cur.execute("INSERT INTO file VALUES (?, ?)", (file_id, file_path)) - for ctx in contexts: - if ctx not in seen_contexts: - ctx_id += 1 - seen_contexts[ctx] = ctx_id - cur.execute("INSERT INTO context VALUES (?, ?)", (ctx_id, ctx)) - cur.execute( - "INSERT INTO line_bits VALUES (?, ?, ?)", (file_id, seen_contexts[ctx], b"\x01") - ) - - cur.execute("INSERT INTO context VALUES (?, ?)", (ctx_id + 1, "")) - conn.commit() - conn.close()