diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..efd5afec --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,132 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +# CodeRabbit Configuration for Seer - Sentry's AI/ML Service +# Documentation: https://docs.coderabbit.ai/guides/configure-coderabbit + +language: en-US + +early_access: true + +reviews: + # Enable high-quality reviews + high_level_summary: true + high_level_summary_placeholder: "@coderabbitai summary" + + # Review profile - assertive for thorough code review + profile: assertive + + # Request changes when issues found + request_changes_workflow: true + + # Collapse walkthrough for cleaner PR comments + collapse_walkthrough: true + + # Enable poem in reviews (fun touch) + poem: false + + # Review status - show in PR + review_status: true + + # Auto-review settings + auto_review: + enabled: true + auto_incremental_review: true + drafts: false # Don't review draft PRs + base_branches: + - main + - master + + # Path-based review instructions + path_instructions: + - path: "src/seer/automation/**/*.py" + instructions: | + Focus on: + - LLM prompt injection vulnerabilities + - Proper error handling for external API calls (GitHub, GitLab, OpenAI, Anthropic) + - Resource cleanup (temp directories, file handles) + - Timeout handling for long-running operations + - Type safety with abstract base classes + + - path: "src/seer/automation/codebase/**/*.py" + instructions: | + This is the repository client layer. Pay attention to: + - Consistent return types between GitHub and GitLab implementations + - Proper authentication token handling (never log tokens) + - Rate limiting considerations + - Branch/commit SHA validation + + - path: "src/seer/automation/agent/**/*.py" + instructions: | + This is the LLM client layer. Check for: + - Multi-provider compatibility (Anthropic, OpenAI, Google) + - Proper streaming/timeout handling + - Token counting and context limits + - Fallback logic between regions/models + + - path: "tests/**/*.py" + instructions: | + Ensure tests: + - Use real database connections, not mocks (per project guidelines) + - Don't test logging or mock behavior + - Use dependency injection for isolation + - Have meaningful assertions + + - path: "**/*migration*.py" + instructions: | + Database migrations require extra scrutiny: + - Check for data loss risks + - Verify rollback capability + - Consider performance on large tables + + - path: "src/seer/configuration.py" + instructions: | + Configuration changes: + - Ensure no secrets have default values + - Check for proper type annotations + - Verify environment variable naming consistency + + # Tools configuration + tools: + # Enable AST-based analysis + ast-grep: + enabled: true + + # Python-specific tools + ruff: + enabled: true + + # Security scanning + semgrep: + enabled: true + + # Shell script checking + shellcheck: + enabled: true + + # GitHub Actions validation + actionlint: + enabled: true + + # Markdown linting + markdownlint: + enabled: true + + # YAML validation + yamllint: + enabled: true + + # Biome for any JS/TS + biome: + enabled: true + +chat: + auto_reply: true + +# Knowledge base for better context +knowledge_base: + opt_out: false + learnings: + scope: auto + issues: + scope: auto + pull_requests: + scope: auto diff --git a/.github/workflows/build-push-gcp.yml b/.github/workflows/build-push-gcp.yml new file mode 100644 index 00000000..b68ff2a6 --- /dev/null +++ b/.github/workflows/build-push-gcp.yml @@ -0,0 +1,117 @@ +name: Build and Push to GCP Artifact Registry + +on: + push: + branches: + - main + paths-ignore: + - '**.md' + - '.coderabbit.yaml' + pull_request: + branches: + - main + types: [closed] + workflow_dispatch: + inputs: + tag: + description: 'Image tag (defaults to commit SHA)' + required: false + type: string + +env: + GCP_PROJECT_ID: kencove-prod + GCP_REGION: us-central1 + REPOSITORY: kencove-docker-repo + IMAGE_NAME: seer + +jobs: + build-and-push: + runs-on: ubuntu-latest + # Only run on push to main, manual trigger, or merged PRs + if: | + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.event.pull_request.merged == true) + + permissions: + contents: read + id-token: write # Required for Workload Identity Federation + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Authenticate to Google Cloud + id: auth + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY }} + # Alternative: Use Workload Identity Federation (more secure, requires GCP setup) + # workload_identity_provider: 'projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID' + # service_account: 'SA_NAME@PROJECT_ID.iam.gserviceaccount.com' + + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker ${{ env.GCP_REGION }}-docker.pkg.dev --quiet + + - name: Generate image tags + id: tags + env: + # Pass user-controlled inputs through env vars to prevent script injection + INPUT_TAG: ${{ inputs.tag }} + HEAD_REF: ${{ github.head_ref }} + run: | + REGISTRY="${{ env.GCP_REGION }}-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}" + SHA_SHORT=$(git rev-parse --short HEAD) + + # Use input tag if provided, otherwise use commit SHA + if [ -n "$INPUT_TAG" ]; then + CUSTOM_TAG="$INPUT_TAG" + else + CUSTOM_TAG="${SHA_SHORT}" + fi + + # Build tags list + TAGS="${REGISTRY}:${CUSTOM_TAG}" + TAGS="${TAGS},${REGISTRY}:${SHA_SHORT}" + + # Add 'latest' tag only on main branch push + if [ "${{ github.ref }}" == "refs/heads/main" ] && [ "${{ github.event_name }}" == "push" ]; then + TAGS="${TAGS},${REGISTRY}:latest" + fi + + # Add branch name tag for PRs + if [ "${{ github.event_name }}" == "pull_request" ]; then + BRANCH_TAG=$(echo "$HEAD_REF" | sed 's/[^a-zA-Z0-9]/-/g' | cut -c1-50) + TAGS="${TAGS},${REGISTRY}:${BRANCH_TAG}" + fi + + echo "tags=${TAGS}" >> $GITHUB_OUTPUT + echo "sha_short=${SHA_SHORT}" >> $GITHUB_OUTPUT + echo "Generated tags: ${TAGS}" + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{ steps.tags.outputs.tags }} + build-args: | + SEER_VERSION_SHA=${{ steps.tags.outputs.sha_short }} + SENTRY_ENVIRONMENT=production + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Output image info + run: | + echo "### Docker Image Published :rocket:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Registry:** ${{ env.GCP_REGION }}-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Tags:**" >> $GITHUB_STEP_SUMMARY + echo '${{ steps.tags.outputs.tags }}' | tr ',' '\n' | while read tag; do + echo "- \`${tag}\`" >> $GITHUB_STEP_SUMMARY + done diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 80deb434..6f9f4854 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -48,17 +48,18 @@ jobs: # --show-diff-on-failure will display what needs to be fixed without making changes xargs pre-commit run --show-diff-on-failure --files + # Auto-fix requires Sentry's internal GitHub App - skip for forks - name: Get auth token id: token - if: ${{ steps.pre-commit_results.outcome == 'failure' }} + if: ${{ steps.pre-commit_results.outcome == 'failure' && vars.SENTRY_INTERNAL_APP_ID != '' }} + continue-on-error: true uses: getsentry/action-github-app-token@d4b5da6c5e37703f8c3b3e43abb5705b46e159cc # v3.0.0 with: app_id: ${{ vars.SENTRY_INTERNAL_APP_ID }} private_key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }} - name: Apply any pre-commit fixed files - if: ${{ steps.pre-commit_results.outcome == 'failure' }} - # note: this runs "always" or else it's skipped when pre-commit fails + if: ${{ steps.pre-commit_results.outcome == 'failure' && steps.token.outputs.token != '' }} uses: getsentry/action-github-commit@5972d5f578ad77306063449e718c0c2a6fbc4ae1 # v2.1.0 with: github-token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ffdb08db..861337a1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,23 +31,25 @@ jobs: uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 - id: "auth" - uses: google-github-actions/auth@3a3c4c57d294ef65efaaee4ff17b22fa88dd3c69 # v1 + uses: google-github-actions/auth@v2 + continue-on-error: true with: - workload_identity_provider: "projects/868781662168/locations/global/workloadIdentityPools/prod-github/providers/github-oidc-pool" - service_account: "gha-seer-models@sac-prod-sa.iam.gserviceaccount.com" - token_format: "id_token" - id_token_audience: "610575311308-9bsjtgqg4jm01mt058rncpopujgk3627.apps.googleusercontent.com" - id_token_include_email: true - create_credentials_file: true + credentials_json: ${{ secrets.GCP_SA_KEY }} + + - name: Compute requirements hash + id: req-hash + run: echo "hash=$(sha256sum requirements.txt | cut -c1-8)" >> $GITHUB_OUTPUT - name: Build and push Docker image run: | make .env + # Use requirements hash in cache key to invalidate when deps change + CACHE_KEY="ghcr.io/${{ github.repository_owner }}/seer:cache-${{ steps.req-hash.outputs.hash }}" docker buildx bake --file docker-compose.yml --file docker-compose-cache.json \ - --set *.cache-to=type=registry,ref=ghcr.io/getsentry/seer:cache,mode=max \ - --set *.cache-from=type=registry,ref=ghcr.io/getsentry/seer:cache \ + --set *.cache-to=type=registry,ref=${CACHE_KEY},mode=max \ + --set *.cache-from=type=registry,ref=${CACHE_KEY} \ --set *.output=type=registry \ - --set *.tags=ghcr.io/getsentry/seer:cache-${{ github.sha }} + --set *.tags=ghcr.io/${{ github.repository_owner }}/seer:cache-${{ github.sha }} typecheck: needs: [build_and_push] @@ -70,7 +72,7 @@ jobs: - name: Pull pre-built Docker image run: | - docker pull ghcr.io/getsentry/seer:${IMAGE_TAG} + docker pull ghcr.io/${{ github.repository_owner }}/seer:${IMAGE_TAG} cp docker-compose.ci.yml docker-compose.override.yml - name: Create blank .env @@ -105,21 +107,17 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - id: "auth" - uses: google-github-actions/auth@3a3c4c57d294ef65efaaee4ff17b22fa88dd3c69 # v1 + uses: google-github-actions/auth@v2 + continue-on-error: true with: - workload_identity_provider: "projects/868781662168/locations/global/workloadIdentityPools/prod-github/providers/github-oidc-pool" - service_account: "gha-seer-models@sac-prod-sa.iam.gserviceaccount.com" - token_format: "id_token" - id_token_audience: "610575311308-9bsjtgqg4jm01mt058rncpopujgk3627.apps.googleusercontent.com" - id_token_include_email: true - create_credentials_file: true + credentials_json: ${{ secrets.GCP_SA_KEY }} - name: Set up Cloud SDK uses: google-github-actions/setup-gcloud@e30db14379863a8c79331b04a9969f4c1e225e0b # v1 - name: Pull pre-built Docker image run: | - docker pull ghcr.io/getsentry/seer:${IMAGE_TAG} + docker pull ghcr.io/${{ github.repository_owner }}/seer:${IMAGE_TAG} cp docker-compose.ci.yml docker-compose.override.yml - name: Create blank .env @@ -138,16 +136,22 @@ jobs: - name: Fetch models if: github.event_name == 'push' + continue-on-error: true run: | rm -rf ./models - gcloud storage cp -r gs://sentry-ml/seer/models ./ + gcloud storage cp -r gs://sentry-ml/seer/models ./ || { + echo "Models not accessible, using NO_REAL_MODELS mode" + mkdir -p models + echo "# Placeholder" > models/.keep + } - name: Set test environment flags run: | - if [[ "${{ github.event_name }}" == "push" ]]; then - echo "EXTRA_COMPOSE_TEST_OPTIONS=-e NO_SENTRY_INTEGRATION=1 -e CI=1" >> $GITHUB_ENV + # Check if models directory has real models (not just placeholder) + if [[ -d "./models" && $(find ./models -type f ! -name '.keep' ! -name '.gitignore' | head -1) ]]; then + echo "EXTRA_COMPOSE_TEST_OPTIONS=-e NO_SENTRY_INTEGRATION=1 -e CI=1 -e GITHUB_TOKEN=${{ secrets.GH_PAT }}" >> $GITHUB_ENV else - echo "EXTRA_COMPOSE_TEST_OPTIONS=-e NO_REAL_MODELS=1 -e NO_SENTRY_INTEGRATION=1 -e CI=1" >> $GITHUB_ENV + echo "EXTRA_COMPOSE_TEST_OPTIONS=-e NO_REAL_MODELS=1 -e NO_SENTRY_INTEGRATION=1 -e CI=1 -e GITHUB_TOKEN=${{ secrets.GH_PAT }}" >> $GITHUB_ENV fi - name: Test with pytest diff --git a/.gitignore b/.gitignore index 3ecd5692..bb047de1 100644 --- a/.gitignore +++ b/.gitignore @@ -151,3 +151,7 @@ dmypy.json # Cassettes tests/**/cassettes/ + +# Local config files +CLAUDE.md +entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 04feda58..997b5b65 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,7 @@ COPY pyproject.toml . # Install dependencies COPY setup.py requirements.txt ./ RUN pip install --upgrade pip==24.0 -RUN pip install -r requirements.txt --no-cache-dir +RUN pip install -r requirements.txt --no-cache-dir && pip check # Copy model files (assuming they are in the 'models' directory) COPY models/ models/ diff --git a/Lightweight.Dockerfile b/Lightweight.Dockerfile index 7b7b8f53..072d7f3a 100644 --- a/Lightweight.Dockerfile +++ b/Lightweight.Dockerfile @@ -21,6 +21,9 @@ RUN apt-get update && \ git && \ rm -rf /var/lib/apt/lists/* +# Install uv for faster dependency management +RUN pip install uv + # Install td-grpc-bootstrap RUN curl -L https://storage.googleapis.com/traffic-director/td-grpc-bootstrap-0.16.0.tar.gz | tar -xz && \ mv td-grpc-bootstrap-0.16.0/td-grpc-bootstrap /usr/local/td-grpc-bootstrap && \ @@ -28,12 +31,12 @@ RUN curl -L https://storage.googleapis.com/traffic-director/td-grpc-bootstrap-0. COPY pyproject.toml . -# Install dependencies +# Install dependencies with uv (faster than pip) COPY setup.py requirements.txt ./ -RUN pip install --upgrade pip==24.0 -# pytorch without gpu -RUN pip install torch==2.2.0 --index-url https://download.pytorch.org/whl/cpu -RUN pip install -r requirements.txt --no-cache-dir +# pytorch without gpu (increase timeout for large downloads) +ENV UV_HTTP_TIMEOUT=300 +RUN uv pip install --system torch==2.2.0 --index-url https://download.pytorch.org/whl/cpu +RUN uv pip install --system -r requirements.txt # Copy model files (assuming they are in the 'models' directory) COPY models/ models/ @@ -51,7 +54,7 @@ COPY supervisord.conf /etc/supervisord.conf # Ignore dependencies, as they are already installed and docker handles the caching # this skips annoying rebuilds where requirements would technically be met anyways. -RUN pip install --default-timeout=120 -e . --no-cache-dir --no-deps +RUN uv pip install --system -e . --no-deps ENV FLASK_APP=src.seer.app:start_app() diff --git a/Makefile b/Makefile index c0557fe9..f139bd72 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,12 @@ help: pip: # Runs pip install with the requirements.txt file pip install -r requirements.txt +.PHONY: validate-deps +validate-deps: # Quick check that all dependencies resolve correctly + @echo "Checking dependency resolution..." + pip-compile --dry-run --quiet requirements-constraints.txt -o /dev/null 2>&1 || (echo "Dependencies failed to resolve"; exit 1) + @echo "Dependencies resolve successfully" + .PHONY: shell shell: .env # Opens a bash shell in the context of the project docker compose run app bash @@ -117,11 +123,14 @@ vcr-encrypt-prep: pip install -r scripts/requirements.txt gcloud auth application-default login +# VCR cassette encryption key URI - using kencove-prod GCP KMS +VCR_KEK_URI:=gcp-kms://projects/kencove-prod/locations/global/keyRings/seer-cassettes/cryptoKeys/cassette-encryption + .PHONY: vcr-encrypt CLEAN:=1 vcr-encrypt: # Encrypts all vcr cassettes - python3 ./scripts/encrypt.py --mode=encrypt --kek_uri=gcp-kms://projects/ml-ai-420606/locations/global/keyRings/seer_cassette_encryption/cryptoKeys/seer_cassette_encryption $(if $(filter 0,$(CLEAN)),,--clean) + python3 ./scripts/encrypt.py --mode=encrypt --kek_uri=$(VCR_KEK_URI) $(if $(filter 0,$(CLEAN)),,--clean) .PHONY: vcr-decrypt vcr-decrypt: # Decrypts all vcr cassettes. Use make vcr-decrypt CLEAN=1 to include --clean flag - python3 ./scripts/encrypt.py --mode=decrypt --kek_uri=gcp-kms://projects/ml-ai-420606/locations/global/keyRings/seer_cassette_encryption/cryptoKeys/seer_cassette_encryption $(if $(CLEAN) = 1,--clean,) + python3 ./scripts/encrypt.py --mode=decrypt --kek_uri=$(VCR_KEK_URI) $(if $(CLEAN) = 1,--clean,) diff --git a/cloudbuild.yaml b/cloudbuild.yaml index ba6c728e..e835daed 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -1,31 +1,34 @@ steps: +# Try to copy models from Sentry's bucket, fallback to placeholder if no access - name: 'gcr.io/cloud-builders/gsutil' - args: ['cp', '-r', 'gs://sentry-ml/seer/models/*', './models'] + entrypoint: 'bash' + args: + - '-c' + - | + gsutil cp -r gs://sentry-ml/seer/models/* ./models 2>/dev/null || { + echo "Models bucket not accessible, creating placeholder..." + mkdir -p models + echo "# Placeholder - models not available" > models/.keep + } - name: 'gcr.io/cloud-builders/docker' args: [ 'build', - '-t', 'us-central1-docker.pkg.dev/$PROJECT_ID/seer/image:$COMMIT_SHA', - '-t', 'us-central1-docker.pkg.dev/$PROJECT_ID/seer/image:latest', + '-t', 'us-central1-docker.pkg.dev/$PROJECT_ID/kencove-docker-repo/seer:$COMMIT_SHA', + '-t', 'us-central1-docker.pkg.dev/$PROJECT_ID/kencove-docker-repo/seer:$SHORT_SHA', + '-t', 'us-central1-docker.pkg.dev/$PROJECT_ID/kencove-docker-repo/seer:latest', '--build-arg', 'BUILDKIT_INLINE_CACHE=1', '--build-arg', 'SEER_VERSION_SHA=$COMMIT_SHA', - '--cache-from', 'us-central1-docker.pkg.dev/$PROJECT_ID/seer/image:latest', + '--cache-from', 'us-central1-docker.pkg.dev/$PROJECT_ID/kencove-docker-repo/seer:latest', '.', ] env: [DOCKER_BUILDKIT=1] -- name: 'gcr.io/cloud-builders/docker' - entrypoint: 'bash' - args: - - '-c' - - | - # Only push "latest" tag when building on "main" - [ "$BRANCH_NAME" != "main" ] && exit 0 - docker push us-central1-docker.pkg.dev/$PROJECT_ID/seer/image:latest +images: + - 'us-central1-docker.pkg.dev/$PROJECT_ID/kencove-docker-repo/seer:$COMMIT_SHA' + - 'us-central1-docker.pkg.dev/$PROJECT_ID/kencove-docker-repo/seer:$SHORT_SHA' + - 'us-central1-docker.pkg.dev/$PROJECT_ID/kencove-docker-repo/seer:latest' -# This is needed for Freight to find matching builds -images: [ - 'us-central1-docker.pkg.dev/$PROJECT_ID/seer/image:$COMMIT_SHA', -] +timeout: '1800s' diff --git a/docker-compose-cache.json b/docker-compose-cache.json index b99e2b6e..dcd13805 100644 --- a/docker-compose-cache.json +++ b/docker-compose-cache.json @@ -1,8 +1,6 @@ { "target": { "app": { - "cache-from": ["type=registry,ref=ghcr.io/getsentry/seer:cache"], - "cache-to": ["type=registry,ref=ghcr.io/getsentry/seer:cache"], "output": ["type=docker"] } } diff --git a/docker-compose.yml b/docker-compose.yml index 6e969274..2cf106b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,7 +63,7 @@ services: - CELERY_BROKER_URL=amqp://guest:guest@rabbitmq:5672// - DATABASE_URL=postgresql+psycopg://root:seer@db/seer - GOOGLE_APPLICATION_CREDENTIALS=/root/.config/gcloud/application_default_credentials.json - - GOOGLE_CLOUD_PROJECT=ml-ai-420606 + - GOOGLE_CLOUD_PROJECT=kencove-prod - SENTRY_REGION=us - IGNORE_API_AUTH=1 - CODEBASE_GCS_STORAGE_BUCKET=autofix-repositories-local diff --git a/requirements-constraints.txt b/requirements-constraints.txt index 4cac694c..bab29bf5 100644 --- a/requirements-constraints.txt +++ b/requirements-constraints.txt @@ -10,7 +10,7 @@ Cython==3.0.2 ephem==4.1.4 filelock==3.12.2 Flask==2.2.* -fonttools==4.43.0 +fonttools>=4.60.2 fsspec==2023.6.0 gunicorn==22.* holidays==0.31 @@ -32,12 +32,15 @@ pandas==2.0.3 patsy==0.5.3 Pillow==10.3.0 PyGithub==2.1.1 +python-gitlab>=4.0.0,<5.0.0 pyparsing==3.0.9 python-dateutil==2.8.2 pytz==2021.3 PyYAML==6.0.1 regex==2023.8.8 -requests==2.32.2 +redis>=4.0.0 +async-timeout>=4.0.0 +requests>=2.32.4 scikit-learn==1.3.0 scipy==1.11.2 seaborn==0.12.2 @@ -96,7 +99,7 @@ google-cloud-storage==2.* google-cloud-aiplatform==1.* google-cloud-secret-manager==2.* anthropic[vertex]==0.* -langfuse @ git+https://github.com/jennmueng/langfuse-python.git@d7c0127682ddb20f73c5cf4fbb396cdfa8961fc3 +langfuse>=3.0.0 watchdog stumpy==1.13.0 pytest_alembic==0.11.1 @@ -118,3 +121,4 @@ tsmoothie==1.0.* datadog==0.51.* flower==2.0.1 GitPython==3.1.* +cachetools>=5.0.0 diff --git a/requirements.txt b/requirements.txt index 255f2683..47f5f68f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,39 +1,42 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --output-file=requirements.txt --strip-extras requirements-constraints.txt # aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.11.18 +aiohttp==3.13.3 # via # -r requirements-constraints.txt # fsspec -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -alembic==1.15.2 +alembic==1.18.2 # via # flask-migrate # pytest-alembic amqp==5.3.1 # via kombu +annotated-doc==0.0.4 + # via fastapi annotated-types==0.7.0 # via pydantic -anthropic==0.51.0 +anthropic==0.76.0 # via -r requirements-constraints.txt -anyio==4.9.0 +anyio==4.12.1 # via # anthropic # google-genai # httpx - # langfuse # openai # starlette # watchfiles -asgiref==3.8.1 +asgiref==3.11.0 # via openapi-core -attrs==25.3.0 +async-timeout==5.0.1 + # via -r requirements-constraints.txt +attrs==25.4.0 # via # aiohttp # jsonschema @@ -42,23 +45,23 @@ backoff==2.2.1 # via # langfuse # posthog -bcrypt==4.3.0 +bcrypt==5.0.0 # via chromadb -billiard==4.2.1 +billiard==4.2.4 # via celery blinker==1.9.0 # via sentry-sdk -build==1.2.2.post1 +build==1.4.0 # via pip-tools -cachetools==5.5.2 - # via google-auth +cachetools==6.2.6 + # via -r requirements-constraints.txt celery==5.3.6 # via # -r requirements-constraints.txt # flower celery-stubs==0.1.3 # via -r requirements-constraints.txt -certifi==2025.4.26 +certifi==2026.1.4 # via # -r requirements-constraints.txt # httpcore @@ -66,7 +69,7 @@ certifi==2025.4.26 # pulsar-client # requests # sentry-sdk -cffi==1.17.1 +cffi==2.0.0 # via # cryptography # pynacl @@ -94,11 +97,11 @@ click==8.1.8 # uvicorn click-didyoumean==0.3.1 # via celery -click-plugins==1.1.1 +click-plugins==1.1.1.2 # via celery click-repl==0.3.0 # via celery -cmdstanpy==1.2.5 +cmdstanpy==1.3.0 # via prophet coloredlogs==15.0.1 # via @@ -110,13 +113,14 @@ contourpy==1.1.1 # matplotlib covdefaults==2.3.0 # via -r requirements-constraints.txt -coverage==7.8.0 +coverage==7.13.2 # via # covdefaults # pytest-cov cryptography==43.0.3 # via # -r requirements-constraints.txt + # google-auth # pyjwt cycler==0.11.0 # via @@ -126,11 +130,11 @@ cython==3.0.2 # via -r requirements-constraints.txt datadog==0.51.0 # via -r requirements-constraints.txt -datasets==3.6.0 +datasets==4.5.0 # via optimum -deprecated==1.2.18 +deprecated==1.3.1 # via pygithub -dill==0.3.8 +dill==0.4.0 # via # datasets # multiprocess @@ -139,11 +143,13 @@ distro==1.9.0 # anthropic # openai # posthog -docstring-parser==0.16 - # via google-cloud-aiplatform +docstring-parser==0.17.0 + # via + # anthropic + # google-cloud-aiplatform ephem==4.1.4 # via -r requirements-constraints.txt -fastapi==0.115.12 +fastapi==0.125.0 # via chromadb filelock==3.12.2 # via @@ -166,15 +172,15 @@ flask-sqlalchemy==3.1.1 # -r requirements-constraints.txt # flask-migrate # types-flask-migrate -flatbuffers==25.2.10 +flatbuffers==25.12.19 # via onnxruntime flower==2.0.1 # via -r requirements-constraints.txt -fonttools==4.43.0 +fonttools==4.61.1 # via # -r requirements-constraints.txt # matplotlib -frozenlist==1.6.0 +frozenlist==1.8.0 # via # aiohttp # aiosignal @@ -186,9 +192,9 @@ fsspec==2023.6.0 # torch gitdb==4.0.12 # via gitpython -gitpython==3.1.44 +gitpython==3.1.46 # via -r requirements-constraints.txt -google-api-core==2.25.0rc1 +google-api-core==2.29.0 # via # google-cloud-aiplatform # google-cloud-bigquery @@ -196,7 +202,7 @@ google-api-core==2.25.0rc1 # google-cloud-resource-manager # google-cloud-secret-manager # google-cloud-storage -google-auth==2.40.1 +google-auth==2.49.0.dev0 # via # anthropic # google-api-core @@ -207,49 +213,52 @@ google-auth==2.40.1 # google-cloud-secret-manager # google-cloud-storage # google-genai -google-cloud-aiplatform==1.92.0 +google-cloud-aiplatform==1.133.0 # via -r requirements-constraints.txt -google-cloud-bigquery==3.32.0 +google-cloud-bigquery==3.40.0 # via google-cloud-aiplatform -google-cloud-core==2.4.3 +google-cloud-core==2.5.0 # via # google-cloud-bigquery # google-cloud-storage -google-cloud-resource-manager==1.14.2 +google-cloud-resource-manager==1.16.0 # via google-cloud-aiplatform -google-cloud-secret-manager==2.23.3 +google-cloud-secret-manager==2.26.0 # via -r requirements-constraints.txt google-cloud-storage==2.19.0 # via # -r requirements-constraints.txt # google-cloud-aiplatform -google-crc32c==1.7.1 +google-crc32c==1.8.0 # via # google-cloud-storage # google-resumable-media -google-genai==1.15.0 +google-genai==1.46.0 # via # -r requirements-constraints.txt # google-cloud-aiplatform -google-resumable-media==2.7.2 +google-resumable-media==2.8.0 # via # google-cloud-bigquery # google-cloud-storage -googleapis-common-protos==1.70.0 +googleapis-common-protos==1.72.0 # via # google-api-core # grpc-google-iam-v1 # grpcio-status -grpc-google-iam-v1==0.14.2 + # opentelemetry-exporter-otlp-proto-http +grpc-google-iam-v1==0.14.3 # via # google-cloud-resource-manager # google-cloud-secret-manager grpc-stubs==1.53.0.6 # via sentry-protos -grpcio==1.71.0 +grpcio==1.76.0 # via # chromadb # google-api-core + # google-cloud-resource-manager + # google-cloud-secret-manager # googleapis-common-protos # grpc-google-iam-v1 # grpc-stubs @@ -257,11 +266,11 @@ grpcio==1.71.0 # grpcio-reflection # grpcio-status # sentry-protos -grpcio-health-checking==1.71.0 +grpcio-health-checking==1.71.2 # via -r requirements-constraints.txt -grpcio-reflection==1.71.0 +grpcio-reflection==1.71.2 # via -r requirements-constraints.txt -grpcio-status==1.71.0 +grpcio-status==1.71.2 # via google-api-core gunicorn==22.0.0 # via -r requirements-constraints.txt @@ -269,22 +278,25 @@ h11==0.16.0 # via # httpcore # uvicorn +hf-xet==1.2.0 + # via huggingface-hub holidays==0.31 # via # -r requirements-constraints.txt # prophet httpcore==1.0.9 # via httpx -httptools==0.6.4 +httptools==0.7.1 # via uvicorn httpx==0.28.1 # via # -r requirements-constraints.txt # anthropic + # datasets # google-genai # langfuse # openai -huggingface-hub==0.31.2 +huggingface-hub==0.36.0 # via # datasets # optimum @@ -293,22 +305,23 @@ huggingface-hub==0.31.2 # transformers humanfriendly==10.0 # via coloredlogs -humanize==4.12.3 +humanize==4.15.0 # via flower -idna==3.10 +idna==3.11 # via # -r requirements-constraints.txt # anyio # httpx - # langfuse # requests # yarl +importlib-metadata==8.7.1 + # via opentelemetry-api importlib-resources==6.0.1 # via # -r requirements-constraints.txt # chromadb # prophet -iniconfig==2.1.0 +iniconfig==2.3.0 # via pytest isodate==0.7.2 # via openapi-core @@ -321,7 +334,7 @@ jinja2==3.1.4 # -r requirements-constraints.txt # flask # torch -jiter==0.9.0 +jiter==0.12.0 # via # anthropic # openai @@ -332,7 +345,7 @@ joblib==1.3.2 # scikit-learn johen==0.1.5 # via -r requirements-constraints.txt -jsonschema==4.23.0 +jsonschema==4.26.0 # via # openapi-core # openapi-schema-validator @@ -351,15 +364,15 @@ kiwisolver==1.4.5 # matplotlib kombu==5.4.2 # via celery -langfuse @ git+https://github.com/jennmueng/langfuse-python.git@d7c0127682ddb20f73c5cf4fbb396cdfa8961fc3 +langfuse==3.12.1 # via -r requirements-constraints.txt -lazy-object-proxy==1.11.0 +lazy-object-proxy==1.12.0 # via openapi-spec-validator -llvmlite==0.44.0 +llvmlite==0.46.0 # via numba mako==1.3.10 # via alembic -markdown-it-py==3.0.0 +markdown-it-py==4.0.0 # via rich markupsafe==2.1.3 # via @@ -368,23 +381,23 @@ markupsafe==2.1.3 # mako # sentry-sdk # werkzeug -matplotlib==3.10.3 +matplotlib==3.10.8 # via # prophet # seaborn mdurl==0.1.2 # via markdown-it-py -more-itertools==10.7.0 +more-itertools==10.8.0 # via openapi-core mpmath==1.3.0 # via # -r requirements-constraints.txt # sympy -multidict==6.4.3 +multidict==6.7.1 # via # aiohttp # yarl -multiprocess==0.70.16 +multiprocess==0.70.18 # via datasets mypy==1.8.0 # via @@ -398,9 +411,9 @@ networkx==3.1 # via # -r requirements-constraints.txt # torch -nltk==3.9.1 +nltk==3.9.2 # via sentence-transformers -numba==0.61.2 +numba==0.63.1 # via stumpy numpy==1.26.1 # via @@ -424,7 +437,6 @@ numpy==1.26.1 # scipy # seaborn # sentence-transformers - # shapely # simdkalman # stanio # statsmodels @@ -433,23 +445,45 @@ numpy==1.26.1 # tsmoothie onnx==1.16.0 # via -r requirements-constraints.txt -onnxruntime==1.22.0 +onnxruntime==1.23.2 # via chromadb -openai==1.78.1 - # via -r requirements-constraints.txt +openai==2.16.0 + # via + # -r requirements-constraints.txt + # langfuse openapi-core==0.18.2 # via -r requirements-constraints.txt openapi-schema-validator==0.6.3 # via # openapi-core # openapi-spec-validator -openapi-spec-validator==0.7.1 +openapi-spec-validator==0.7.2 # via openapi-core +opentelemetry-api==1.39.1 + # via + # langfuse + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-exporter-otlp-proto-common==1.39.1 + # via opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-proto-http==1.39.1 + # via langfuse +opentelemetry-proto==1.39.1 + # via + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-http +opentelemetry-sdk==1.39.1 + # via + # langfuse + # opentelemetry-exporter-otlp-proto-http +opentelemetry-semantic-conventions==0.60b1 + # via opentelemetry-sdk optimum==1.16.2 # via -r requirements-constraints.txt overrides==7.7.0 # via chromadb -packaging==24.2 +packaging==25.0 # via # -r requirements-constraints.txt # build @@ -465,6 +499,7 @@ packaging==24.2 # pytest # statsmodels # transformers + # wheel pandas==2.0.3 # via # -r requirements-constraints.txt @@ -494,28 +529,28 @@ pillow==10.3.0 # sentence-transformers pip-tools==7.4.1 # via -r requirements-constraints.txt -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -posthog==4.0.1 +posthog==7.7.0 # via chromadb -prometheus-client==0.21.1 +prometheus-client==0.24.1 # via flower -prompt-toolkit==3.0.51 +prompt-toolkit==3.0.52 # via click-repl -propcache==0.3.1 +propcache==0.4.1 # via # aiohttp # yarl -prophet==1.1.6 +prophet==1.1.7 # via -r requirements-constraints.txt -proto-plus==1.26.1 +proto-plus==1.27.0 # via # -r requirements-constraints.txt # google-api-core # google-cloud-aiplatform # google-cloud-resource-manager # google-cloud-secret-manager -protobuf==5.29.4 +protobuf==5.29.5 # via # -r requirements-constraints.txt # google-api-core @@ -529,22 +564,21 @@ protobuf==5.29.4 # grpcio-status # onnx # onnxruntime + # opentelemetry-proto # proto-plus # sentry-protos # transformers psycopg==3.1.18 # via -r requirements-constraints.txt -pulsar-client==3.6.1 +pulsar-client==3.9.0 # via chromadb -pyarrow==20.0.0 +pyarrow==23.0.0 # via datasets -pyasn1==0.6.1 - # via - # pyasn1-modules - # rsa +pyasn1==0.6.2 + # via pyasn1-modules pyasn1-modules==0.4.2 # via google-auth -pycparser==2.22 +pycparser==3.0 # via cffi pydantic==2.6.4 # via @@ -561,21 +595,21 @@ pydantic-core==2.16.3 # via # pydantic # pydantic-xml -pydantic-xml==2.16.0 +pydantic-xml==2.18.0 # via -r requirements-constraints.txt pygithub==2.1.1 # via -r requirements-constraints.txt -pygments==2.19.1 +pygments==2.19.2 # via rich pyjwt==2.10.1 # via pygithub -pynacl==1.5.0 +pynacl==1.6.2 # via pygithub pyparsing==3.0.9 # via # -r requirements-constraints.txt # matplotlib -pypika==0.48.9 +pypika==0.50.0 # via chromadb pyproject-hooks==1.2.0 # via @@ -606,8 +640,10 @@ python-dateutil==2.8.2 # pandas # posthog # pygithub -python-dotenv==1.1.0 +python-dotenv==1.2.1 # via uvicorn +python-gitlab==4.13.0 + # via -r requirements-constraints.txt pytz==2021.3 # via # -r requirements-constraints.txt @@ -625,6 +661,8 @@ pyyaml==6.0.1 # vcrpy rapidfuzz==3.10.1 # via -r requirements-constraints.txt +redis==7.1.0 + # via -r requirements-constraints.txt referencing==0.30.2 # via # jsonschema @@ -637,7 +675,7 @@ regex==2023.8.8 # -r requirements-constraints.txt # nltk # transformers -requests==2.32.2 +requests==2.32.5 # via # -r requirements-constraints.txt # chromadb @@ -653,20 +691,23 @@ requests==2.32.2 # jsonschema-path # jsonschema-spec # langfuse + # opentelemetry-exporter-otlp-proto-http # posthog # pygithub + # python-gitlab + # requests-toolbelt # transformers +requests-toolbelt==1.0.0 + # via python-gitlab rfc3339-validator==0.1.4 # via openapi-schema-validator -rich==14.0.0 +rich==14.3.1 # via typer -rpds-py==0.24.0 +rpds-py==0.30.0 # via # jsonschema # referencing -rsa==4.9.1 - # via google-auth -safetensors==0.5.3 +safetensors==0.7.0 # via transformers scikit-learn==1.3.0 # via @@ -684,16 +725,14 @@ seaborn==0.12.2 # via -r requirements-constraints.txt sentence-transformers==2.3.1 # via -r requirements-constraints.txt -sentencepiece==0.2.0 +sentencepiece==0.2.1 # via # sentence-transformers # transformers -sentry-protos==0.2.0 +sentry-protos==0.5.0 # via -r requirements-constraints.txt -sentry-sdk==2.28.0 +sentry-sdk==2.51.0 # via -r requirements-constraints.txt -shapely==2.1.0 - # via google-cloud-aiplatform shellingham==1.5.4 # via typer simdkalman==1.0.2 @@ -712,7 +751,6 @@ smmap==5.0.2 sniffio==1.3.1 # via # anthropic - # anyio # openai sqlalchemy==2.0.25 # via @@ -722,7 +760,7 @@ sqlalchemy==2.0.25 # pytest-alembic stanio==0.5.1 # via cmdstanpy -starlette==0.46.2 +starlette==0.50.0 # via fastapi statsmodels==0.14.0 # via -r requirements-constraints.txt @@ -734,6 +772,8 @@ sympy==1.12 # onnxruntime # optimum # torch +tenacity==9.1.2 + # via google-genai threadpoolctl==3.2.0 # via # -r requirements-constraints.txt @@ -747,7 +787,7 @@ torch==2.2.0 # -r requirements-constraints.txt # optimum # sentence-transformers -tornado==6.4.2 +tornado==6.5.4 # via flower tqdm==4.66.3 # via @@ -774,11 +814,11 @@ tree-sitter-languages==1.10.2 # via -r requirements-constraints.txt tsmoothie==1.0.5 # via -r requirements-constraints.txt -typer==0.15.3 +typer==0.21.1 # via chromadb types-colorama==0.4.15.12 # via -r requirements-constraints.txt -types-flask-migrate==4.1.0.20250112 +types-flask-migrate==4.1.0.20250809 # via -r requirements-constraints.txt types-jsonschema==4.20.0.20240105 # via -r requirements-constraints.txt @@ -804,9 +844,10 @@ types-tabulate==0.9.0.3 # via -r requirements-constraints.txt types-tqdm==4.66.0.5 # via -r requirements-constraints.txt -typing-extensions==4.13.2 +typing-extensions==4.15.0 # via # -r requirements-constraints.txt + # aiosignal # alembic # anthropic # anyio @@ -815,14 +856,21 @@ typing-extensions==4.13.2 # fastapi # google-cloud-aiplatform # google-genai + # grpcio # huggingface-hub # mypy # openai + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # posthog # psycopg # pydantic # pydantic-core # pygithub # sqlalchemy + # starlette # torch # typer tzdata==2023.3 @@ -840,9 +888,9 @@ urllib3==1.26.19 # requests # sentry-sdk # vcrpy -uvicorn==0.34.2 +uvicorn==0.40.0 # via chromadb -uvloop==0.21.0 +uvloop==0.22.1 # via uvicorn vcrpy==6.0.2 # via @@ -855,9 +903,9 @@ vine==5.1.0 # kombu watchdog==6.0.0 # via -r requirements-constraints.txt -watchfiles==1.0.5 +watchfiles==1.1.1 # via uvicorn -wcwidth==0.2.13 +wcwidth==0.5.0 # via prompt-toolkit websockets==15.0.1 # via @@ -868,19 +916,21 @@ werkzeug==3.0.3 # -r requirements-constraints.txt # flask # openapi-core -wheel==0.45.1 +wheel==0.46.3 # via pip-tools -wrapt==1.17.2 +wrapt==1.17.3 # via # deprecated # langfuse # vcrpy -xxhash==3.5.0 +xxhash==3.6.0 # via datasets -yarl==1.20.0 +yarl==1.22.0 # via # aiohttp # vcrpy +zipp==3.23.0 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/setup.cfg b/setup.cfg index f742c6b0..ebe13ca7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,8 +16,11 @@ # LOG005 - Use exception() within an exception handler # LOG010 - exception() does not take an exception # LOG011 - Avoid pre-formatting log messages +# D1XX - Missing docstrings (module, class, method, function) +# D2XX - Docstring whitespace/formatting issues +# D4XX - Docstring content issues (first line should end with period, etc.) -extend-ignore = E203, E501, E731, LOG005, LOG010, LOG011 +extend-ignore = E203, E501, E731, LOG005, LOG010, LOG011, D100, D101, D102, D103, D104, D105, D106, D107, D200, D202, D205, D400, D401 [coverage:run] omit = diff --git a/src/seer/app.py b/src/seer/app.py index 8d043140..213acd38 100644 --- a/src/seer/app.py +++ b/src/seer/app.py @@ -96,6 +96,30 @@ codegen_unittest, get_unittest_state, ) +from seer.automation.explorer.models import ( + AutofixPromptRequest, + AutofixPromptResponse, + CodegenPrReviewRerunRequest, + CodegenPrReviewRerunResponse, + CodingAgentStateSetRequest, + CodingAgentStateSetResponse, + CodingAgentStateUpdateRequest, + CodingAgentStateUpdateResponse, + ExplorerChatRequest, + ExplorerChatResponse, + ExplorerRunsRequest, + ExplorerRunsResponse, + ExplorerStateRequest, + ExplorerStateResponse, + ExplorerUpdateRequest, + ExplorerUpdateResponse, + ProjectPreferenceBulkRequest, + ProjectPreferenceBulkResponse, + ProjectPreferenceBulkSetRequest, + ProjectPreferenceBulkSetResponse, +) +from seer.automation.explorer.state import ExplorerRunState +from seer.automation.explorer.tasks import process_explorer_chat from seer.automation.preferences import ( GetSeerProjectPreferenceRequest, GetSeerProjectPreferenceResponse, @@ -639,6 +663,217 @@ def translate_endpoint(data: TranslateRequest) -> TranslateResponses: return response +# ============================================================================= +# Explorer Endpoints +# ============================================================================= +# These endpoints provide LLM-powered chat functionality for issue analysis. +# Requires ANTHROPIC_API_KEY environment variable to be set. + + +@json_api(blueprint, "/v1/automation/explorer/runs") +def explorer_runs_endpoint(data: ExplorerRunsRequest) -> ExplorerRunsResponse: + """List explorer runs for an organization.""" + try: + runs = ExplorerRunState.list( + organization_id=data.organization_id, + category_key=data.category_key, + category_value=data.category_value, + ) + return ExplorerRunsResponse(data=runs) + except Exception as e: + logger.exception(f"Error listing explorer runs: {e}") + return ExplorerRunsResponse(data=[]) + + +@json_api(blueprint, "/v1/automation/explorer/chat") +def explorer_chat_endpoint(data: ExplorerChatRequest) -> ExplorerChatResponse: + """Process an explorer chat message.""" + import os + + app_config = resolve(AppConfig) + + # Check if Anthropic API key is configured + if not app_config.ANTHROPIC_API_KEY and not os.environ.get("ANTHROPIC_API_KEY"): + return ExplorerChatResponse( + status="not_available", + message="Explorer requires ANTHROPIC_API_KEY to be configured", + run_id=None, + ) + + try: + # Create new run or get existing + if data.run_id is None: + state = ExplorerRunState.create( + organization_id=data.organization_id, + category_key=data.category_key, + category_value=data.category_value, + metadata=data.metadata, + ) + run_id = state.run_id + else: + state = ExplorerRunState.get(data.run_id) + if state is None: + return ExplorerChatResponse( + status="error", + message=f"Run {data.run_id} not found", + run_id=None, + ) + run_id = data.run_id + + # Get query from request + query = data.get_query() + if not query: + return ExplorerChatResponse( + status="error", + message="No query provided", + run_id=run_id, + ) + + # Convert tools to serializable format + tools_data = None + if data.tools: + tools_data = [t.model_dump(mode="json") for t in data.tools] + + # Queue the processing task + process_explorer_chat.delay( + run_id=run_id, + query=query, + artifact_key=data.artifact_key, + artifact_schema=data.artifact_schema, + tools=tools_data, + metadata=data.metadata, + ) + + return ExplorerChatResponse( + status="processing", + run_id=run_id, + message=None, + ) + + except Exception as e: + logger.exception(f"Error processing explorer chat: {e}") + sentry_sdk.capture_exception(e) + return ExplorerChatResponse( + status="error", + message=str(e), + run_id=data.run_id, + ) + + +@json_api(blueprint, "/v1/automation/explorer/state") +def explorer_state_endpoint(data: ExplorerStateRequest) -> ExplorerStateResponse: + """Get the current state of an explorer run.""" + try: + state = ExplorerRunState.get(data.run_id) + if state is None: + return ExplorerStateResponse( + session=None, + status="not_found", + message=f"Run {data.run_id} not found", + ) + + return ExplorerStateResponse( + session=state.to_seer_run_state(), + status="ok", + message=None, + ) + + except Exception as e: + logger.exception(f"Error getting explorer state: {e}") + return ExplorerStateResponse( + session=None, + status="error", + message=str(e), + ) + + +@json_api(blueprint, "/v1/automation/explorer/update") +def explorer_update_endpoint(data: ExplorerUpdateRequest) -> ExplorerUpdateResponse: + """Update an explorer run.""" + try: + state = ExplorerRunState.get(data.run_id) + if state is None: + return ExplorerUpdateResponse( + status="error", + message=f"Run {data.run_id} not found", + ) + + # Handle different update types + if data.update_type == "cancel": + from seer.automation.explorer.models import ExplorerStatus + + state.set_status(ExplorerStatus.COMPLETED) + state.set_loading(False) + + return ExplorerUpdateResponse(status="ok", message=None) + + except Exception as e: + logger.exception(f"Error updating explorer run: {e}") + return ExplorerUpdateResponse( + status="error", + message=str(e), + ) + + +# ============================================ +# Additional stub endpoints for Sentry 26.x compatibility +# ============================================ + + +@json_api(blueprint, "/v1/automation/autofix/coding-agent/state/set") +def coding_agent_state_set_endpoint( + data: CodingAgentStateSetRequest, +) -> CodingAgentStateSetResponse: + """Set coding agent state - stub for self-hosted.""" + return CodingAgentStateSetResponse( + status="not_available", message="Coding agent not available in self-hosted mode" + ) + + +@json_api(blueprint, "/v1/automation/autofix/coding-agent/state/update") +def coding_agent_state_update_endpoint( + data: CodingAgentStateUpdateRequest, +) -> CodingAgentStateUpdateResponse: + """Update coding agent state - stub for self-hosted.""" + return CodingAgentStateUpdateResponse( + status="not_available", message="Coding agent not available in self-hosted mode" + ) + + +@json_api(blueprint, "/v1/automation/autofix/prompt") +def autofix_prompt_endpoint(data: AutofixPromptRequest) -> AutofixPromptResponse: + """Get autofix prompt - stub for self-hosted.""" + return AutofixPromptResponse( + prompt=None, message="Autofix prompt not available in self-hosted mode" + ) + + +@json_api(blueprint, "/v1/automation/codegen/pr-review/rerun") +def codegen_pr_review_rerun_endpoint( + data: CodegenPrReviewRerunRequest, +) -> CodegenPrReviewRerunResponse: + """Rerun PR review - stub for self-hosted.""" + return CodegenPrReviewRerunResponse( + status="not_available", message="PR review rerun not available in self-hosted mode" + ) + + +@json_api(blueprint, "/v1/project-preference/bulk") +def get_project_preference_bulk_endpoint( + data: ProjectPreferenceBulkRequest, +) -> ProjectPreferenceBulkResponse: + """Get bulk project preferences - stub for self-hosted.""" + return ProjectPreferenceBulkResponse(preferences={}, message="Bulk preferences retrieved") + + +@json_api(blueprint, "/v1/project-preference/bulk-set") +def set_project_preference_bulk_endpoint( + data: ProjectPreferenceBulkSetRequest, +) -> ProjectPreferenceBulkSetResponse: + """Set bulk project preferences - stub for self-hosted.""" + return ProjectPreferenceBulkSetResponse(status="ok", message="Bulk preferences set") + + @blueprint.route("/health/live", methods=["GET"]) @inject def health_check(app_config: AppConfig = injected): diff --git a/src/seer/automation/agent/agent.py b/src/seer/automation/agent/agent.py index 62ceff69..3a5e9b41 100644 --- a/src/seer/automation/agent/agent.py +++ b/src/seer/automation/agent/agent.py @@ -4,7 +4,7 @@ from typing import Optional import sentry_sdk -from langfuse.decorators import langfuse_context, observe +from langfuse import observe from pydantic import BaseModel, Field from seer.automation.agent.client import LlmClient, LlmProvider @@ -13,6 +13,7 @@ from seer.automation.agent.utils import parse_json_with_keys from seer.automation.utils import AgentError from seer.dependency_injection import inject, injected +from seer.langfuse import langfuse_context logger = logging.getLogger("autofix") diff --git a/src/seer/automation/agent/client.py b/src/seer/automation/agent/client.py index 45684af8..8bf0a551 100644 --- a/src/seer/automation/agent/client.py +++ b/src/seer/automation/agent/client.py @@ -32,8 +32,8 @@ ThinkingConfig, ) from google.genai.types import Tool as GeminiTool -from langfuse.decorators import langfuse_context, observe -from langfuse.openai import openai +from langfuse import observe +from langfuse.openai import openai # type: ignore[attr-defined] from openai.types.chat import ChatCompletionMessageParam, ChatCompletionToolParam from requests.exceptions import ChunkedEncodingError @@ -62,6 +62,7 @@ from seer.bootup import module from seer.configuration import AppConfig from seer.dependency_injection import inject, injected +from seer.langfuse import langfuse_context from seer.utils import backoff_on_exception, backoff_on_generator logger = logging.getLogger(__name__) @@ -926,7 +927,7 @@ def to_tool_dict(tool: FunctionTool | ClaudeTool) -> ToolParam: input_schema={ "type": "object", "properties": { - param["name"]: { + str(param["name"]): { # type: ignore[misc] key: value for key, value in { "type": param["type"], diff --git a/src/seer/automation/agent/embeddings.py b/src/seer/automation/agent/embeddings.py index 301a3e52..da7237a0 100644 --- a/src/seer/automation/agent/embeddings.py +++ b/src/seer/automation/agent/embeddings.py @@ -31,7 +31,7 @@ def get_client(self) -> TextEmbeddingModel: retrier = backoff_on_exception( GeminiProvider.is_completion_exception_retryable, max_tries=4 ) - model.get_embeddings = retrier(model.get_embeddings) + model.get_embeddings = retrier(model.get_embeddings) # type: ignore[method-assign] return model @classmethod @@ -93,7 +93,7 @@ def encode( ): text_embedding_inputs = self._prepare_inputs(batch) embeddings_batch = model.get_embeddings( - text_embedding_inputs, + text_embedding_inputs, # type: ignore[arg-type] auto_truncate=auto_truncate, output_dimensionality=self.output_dimensionality, ) diff --git a/src/seer/automation/assisted_query/assisted_query.py b/src/seer/automation/assisted_query/assisted_query.py index b03b7493..b35acff3 100644 --- a/src/seer/automation/assisted_query/assisted_query.py +++ b/src/seer/automation/assisted_query/assisted_query.py @@ -1,7 +1,7 @@ import logging import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from seer.automation.agent.client import LlmClient from seer.automation.agent.models import LlmGenerateStructuredResponse diff --git a/src/seer/automation/autofix/autofix_agent.py b/src/seer/automation/autofix/autofix_agent.py index d0e5223f..61c19fe6 100644 --- a/src/seer/automation/autofix/autofix_agent.py +++ b/src/seer/automation/autofix/autofix_agent.py @@ -4,7 +4,7 @@ from typing import Optional import sentry_sdk -from langfuse.decorators import langfuse_context, observe +from langfuse import observe from seer.automation.agent.agent import AgentConfig, LlmAgent, RunConfig from seer.automation.agent.models import ( @@ -21,6 +21,7 @@ from seer.automation.autofix.models import AutofixContinuation, AutofixStatus, DefaultStep from seer.automation.state import State from seer.dependency_injection import copy_modules_initializer +from seer.langfuse import langfuse_context from seer.utils import retry_once_with_modified_input logger = logging.getLogger(__name__) diff --git a/src/seer/automation/autofix/autofix_context.py b/src/seer/automation/autofix/autofix_context.py index fa83a766..94b6da34 100644 --- a/src/seer/automation/autofix/autofix_context.py +++ b/src/seer/automation/autofix/autofix_context.py @@ -20,6 +20,7 @@ from seer.automation.codebase.file_patches import make_file_patches from seer.automation.codebase.models import BaseDocument from seer.automation.codebase.repo_client import ( + BaseRepoClient, RepoClient, RepoClientType, autocorrect_repo_name, @@ -116,7 +117,7 @@ def get_repo_client( repo_name: str | None = None, repo_external_id: str | None = None, type: RepoClientType = RepoClientType.READ, - ) -> RepoClient: + ) -> BaseRepoClient: return get_repo_client( repos=self.repos, repo_name=repo_name, repo_external_id=repo_external_id, type=type ) diff --git a/src/seer/automation/autofix/components/change_describer.py b/src/seer/automation/autofix/components/change_describer.py index 9c7039b6..3238f492 100644 --- a/src/seer/automation/autofix/components/change_describer.py +++ b/src/seer/automation/autofix/components/change_describer.py @@ -1,7 +1,7 @@ import textwrap import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from seer.automation.agent.client import GeminiProvider, LlmClient from seer.automation.autofix.autofix_context import AutofixContext @@ -77,7 +77,7 @@ def invoke( with self.context.state.update() as cur: cur.usage += output.metadata.usage - if data is None: - return None + if data is None: # type: ignore[unreachable] + return None # type: ignore[unreachable] return data diff --git a/src/seer/automation/autofix/components/coding/component.py b/src/seer/automation/autofix/components/coding/component.py index 246f7735..d4371969 100644 --- a/src/seer/automation/autofix/components/coding/component.py +++ b/src/seer/automation/autofix/components/coding/component.py @@ -2,7 +2,7 @@ import logging import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from seer.automation.agent.agent import AgentConfig, RunConfig from seer.automation.agent.client import AnthropicProvider, LlmClient @@ -176,7 +176,7 @@ def invoke(self, request: CodingRequest) -> None: models=[ AnthropicProvider.model("claude-sonnet-4@20250514"), AnthropicProvider.model("claude-3-7-sonnet@20250219"), - AnthropicProvider.model("claude-3-5-sonnet-v2@20241022"), + AnthropicProvider.model("claude-sonnet-4@20250514"), ], ), ) diff --git a/src/seer/automation/autofix/components/coding/utils.py b/src/seer/automation/autofix/components/coding/utils.py index f884ed95..fe3fc83e 100644 --- a/src/seer/automation/autofix/components/coding/utils.py +++ b/src/seer/automation/autofix/components/coding/utils.py @@ -1,7 +1,7 @@ import logging import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from seer.automation.autofix.components.coding.models import FuzzyDiffChunk, PlanTaskPromptXml from seer.automation.autofix.utils import find_original_snippet diff --git a/src/seer/automation/autofix/components/comment_thread.py b/src/seer/automation/autofix/components/comment_thread.py index 77ffbbe5..c339e887 100644 --- a/src/seer/automation/autofix/components/comment_thread.py +++ b/src/seer/automation/autofix/components/comment_thread.py @@ -1,7 +1,7 @@ import textwrap import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from seer.automation.agent.client import GeminiProvider, LlmClient from seer.automation.agent.models import Message @@ -64,8 +64,8 @@ def invoke( ) data = output.parsed - if data is None: - return CommentThreadOutput( + if data is None: # type: ignore[unreachable] + return CommentThreadOutput( # type: ignore[unreachable] comment_in_response="Sorry, I'm not sure what to say.", action_requested=False, ) diff --git a/src/seer/automation/autofix/components/confidence.py b/src/seer/automation/autofix/components/confidence.py index 8255fcdf..258cbbbe 100644 --- a/src/seer/automation/autofix/components/confidence.py +++ b/src/seer/automation/autofix/components/confidence.py @@ -1,7 +1,7 @@ import textwrap import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from seer.automation.agent.client import GeminiProvider, LlmClient from seer.automation.agent.models import Message @@ -64,8 +64,8 @@ def invoke( ) data = output.parsed - if data is None: - return ConfidenceOutput( + if data is None: # type: ignore[unreachable] + return ConfidenceOutput( # type: ignore[unreachable] output_confidence_score=0.5, proceed_confidence_score=0.5, ) diff --git a/src/seer/automation/autofix/components/insight_sharing/component.py b/src/seer/automation/autofix/components/insight_sharing/component.py index b74043d0..e0b6fbfc 100644 --- a/src/seer/automation/autofix/components/insight_sharing/component.py +++ b/src/seer/automation/autofix/components/insight_sharing/component.py @@ -1,7 +1,7 @@ import textwrap import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from seer.automation.agent.client import GeminiProvider, LlmClient from seer.automation.agent.models import Message, Usage @@ -245,7 +245,7 @@ def create_insight_output( memory = [msg for msg in memory if msg.role != "system"] - completion = llm_client.generate_structured( + structured_completion = llm_client.generate_structured( messages=memory, prompt=prompt_two, model=GeminiProvider.model("gemini-2.0-flash-001"), @@ -253,8 +253,8 @@ def create_insight_output( max_tokens=4096, response_format=JustificationOutput, ) - usage += completion.metadata.usage - justification = completion.parsed + usage += structured_completion.metadata.usage + justification = structured_completion.parsed answer = justification and justification.evidence or "" markdown_snippets = justification and justification.markdown_snippets or "" diff --git a/src/seer/automation/autofix/components/root_cause/component.py b/src/seer/automation/autofix/components/root_cause/component.py index 4abc49a9..5d97dd7d 100644 --- a/src/seer/automation/autofix/components/root_cause/component.py +++ b/src/seer/automation/autofix/components/root_cause/component.py @@ -1,7 +1,7 @@ import logging import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from seer.automation.agent.agent import AgentConfig, RunConfig from seer.automation.agent.client import AnthropicProvider, GeminiProvider, LlmClient @@ -142,12 +142,12 @@ def invoke( "Arranging data in a way that looks intentional..." ) - de_formatter_config = { + de_formatter_config: dict[str, object] = { "model": GeminiProvider.model("gemini-2.0-flash-001"), "max_tokens": 8192, } - us_formatter_config = { + us_formatter_config: dict[str, object] = { "models": [ GeminiProvider.model( "gemini-2.5-flash-preview-04-17", @@ -164,7 +164,7 @@ def invoke( response_format=MultipleRootCauseAnalysisOutputPrompt, run_name="Root Cause Extraction & Formatting", **( - de_formatter_config if config.SENTRY_REGION == "de" else us_formatter_config + de_formatter_config if config.SENTRY_REGION == "de" else us_formatter_config # type: ignore[arg-type] ), ) diff --git a/src/seer/automation/autofix/components/solution/component.py b/src/seer/automation/autofix/components/solution/component.py index 78a0b247..f9d3a8ce 100644 --- a/src/seer/automation/autofix/components/solution/component.py +++ b/src/seer/automation/autofix/components/solution/component.py @@ -2,7 +2,7 @@ import logging import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from seer.automation.agent.agent import AgentConfig, RunConfig from seer.automation.agent.client import AnthropicProvider, GeminiProvider, LlmClient @@ -190,11 +190,11 @@ def invoke( self.context.event_manager.add_log("Formatting for human consumption...") - de_config = { + de_config: dict[str, object] = { "model": GeminiProvider.model("gemini-2.0-flash-001"), } - us_config = { + us_config: dict[str, object] = { "models": [ GeminiProvider.model( "gemini-2.5-flash-preview-04-17", @@ -211,7 +211,7 @@ def invoke( response_format=SolutionOutput, run_name="Solution Extraction & Formatting", max_tokens=8192, - **(de_config if config.SENTRY_REGION == "de" else us_config), + **(de_config if config.SENTRY_REGION == "de" else us_config), # type: ignore[arg-type] ) if not formatted_response or not formatted_response.parsed: diff --git a/src/seer/automation/autofix/evaluations.py b/src/seer/automation/autofix/evaluations.py index 4e07c8c2..3dea9c37 100644 --- a/src/seer/automation/autofix/evaluations.py +++ b/src/seer/automation/autofix/evaluations.py @@ -2,8 +2,10 @@ import textwrap from typing import Literal, TypedDict -from langfuse.client import DatasetItemClient -from langfuse.decorators import observe +from langfuse import observe + +# DatasetItemClient moved to private module in langfuse 3.x +from langfuse._client.client import DatasetItemClient # type: ignore[attr-defined] from pydantic import BaseModel from pydantic_xml import attr, element @@ -354,12 +356,12 @@ def score_solution( if any(result is None for result in results): return None - results = [result for result in results if result is not None] + valid_results: list[tuple[float, bool]] = [result for result in results if result is not None] - mean_score = round(sum([result[0] for result in results]) / n_panel, 2) + mean_score = round(sum([result[0] for result in valid_results]) / n_panel, 2) # If at least half of the panel says the fix is correct, then the fix is correct. - verdict = sum(1 for result in results if result[1]) >= len(results) / 2 + verdict = sum(1 for result in valid_results if result[1]) >= len(valid_results) / 2 return mean_score, verdict @@ -373,10 +375,10 @@ def score_coding( if any(result is None for result in results): return None - results = [result for result in results if result is not None] + valid_results: list[tuple[float, float]] = [result for result in results if result is not None] - mean_correctness_score = round(sum([result[0] for result in results]) / n_panel, 2) - mean_conciseness_score = round(sum([result[1] for result in results]) / n_panel, 2) + mean_correctness_score = round(sum([result[0] for result in valid_results]) / n_panel, 2) + mean_conciseness_score = round(sum([result[1] for result in valid_results]) / n_panel, 2) return mean_correctness_score, mean_conciseness_score @@ -390,14 +392,16 @@ def score_root_causes( if any(result is None for result in results): return None - results = [result for result in results if result is not None] + valid_results: list[tuple[float, bool, bool]] = [ + result for result in results if result is not None + ] - mean_score = round(sum([result[0] for result in results]) / len(results), 2) + mean_score = round(sum([result[0] for result in valid_results]) / len(valid_results), 2) # If at least half of the panel says the fix is correct, then the fix is correct. - verdict = sum(1 for result in results if result[1]) >= len(results) / 2 + verdict = sum(1 for result in valid_results if result[1]) >= len(valid_results) / 2 - helpful = sum(1 for result in results if result[2]) >= len(results) / 2 + helpful = sum(1 for result in valid_results if result[2]) >= len(valid_results) / 2 return mean_score, verdict, helpful diff --git a/src/seer/automation/autofix/steps/change_describer_step.py b/src/seer/automation/autofix/steps/change_describer_step.py index e7418c2d..88441851 100644 --- a/src/seer/automation/autofix/steps/change_describer_step.py +++ b/src/seer/automation/autofix/steps/change_describer_step.py @@ -2,7 +2,7 @@ from typing import Any import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from celery_app.app import celery_app from seer.automation.autofix.components.change_describer import ( diff --git a/src/seer/automation/autofix/steps/coding_step.py b/src/seer/automation/autofix/steps/coding_step.py index aa2d4b96..5b92070b 100644 --- a/src/seer/automation/autofix/steps/coding_step.py +++ b/src/seer/automation/autofix/steps/coding_step.py @@ -2,7 +2,7 @@ from typing import Any import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from celery_app.app import celery_app from seer.automation.agent.models import Message diff --git a/src/seer/automation/autofix/steps/root_cause_step.py b/src/seer/automation/autofix/steps/root_cause_step.py index 372dfdd5..4e1b6b88 100644 --- a/src/seer/automation/autofix/steps/root_cause_step.py +++ b/src/seer/automation/autofix/steps/root_cause_step.py @@ -2,7 +2,7 @@ from typing import Any import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from celery_app.app import celery_app from seer.automation.agent.models import Message diff --git a/src/seer/automation/autofix/steps/solution_step.py b/src/seer/automation/autofix/steps/solution_step.py index 5e1e6350..95d1db2f 100644 --- a/src/seer/automation/autofix/steps/solution_step.py +++ b/src/seer/automation/autofix/steps/solution_step.py @@ -2,7 +2,7 @@ from typing import Any import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from celery_app.app import celery_app from seer.automation.agent.models import Message diff --git a/src/seer/automation/autofix/tasks.py b/src/seer/automation/autofix/tasks.py index 6ca5616a..f67258c4 100644 --- a/src/seer/automation/autofix/tasks.py +++ b/src/seer/automation/autofix/tasks.py @@ -64,6 +64,7 @@ from seer.db import DbPrIdToAutofixRunIdMapping, DbRunState, Session from seer.dependency_injection import inject, injected from seer.events import SeerEventNames, log_seer_event +from seer.langfuse import get_dataset_item from seer.rpc import get_sentry_client logger = logging.getLogger(__name__) @@ -877,6 +878,8 @@ def comment_on_thread(request: AutofixUpdateRequest): ), ) ) + if response is None: + return with state.update() as cur: if request.payload.is_agent_comment: cur.steps[step_index + 1].agent_comment_thread.messages.append( @@ -1008,7 +1011,7 @@ def run_autofix_evaluation_on_item( ): langfuse = Langfuse() - dataset_item = langfuse.get_dataset_item(item_id) + dataset_item = get_dataset_item(langfuse, item_id) logger.info( f"Starting autofix evaluation for item {item_id}, number {item_index}/{item_count}, with run name '{run_name}'." @@ -1018,10 +1021,11 @@ def run_autofix_evaluation_on_item( scoring_model = "o3-mini-2025-01-31" dataset_item_trace_id = None - with dataset_item.observe(run_name=run_name, run_description=run_description) as trace_id: - dataset_item_trace_id = trace_id + # In langfuse 3.x, observe() is replaced by run() which yields a span + with dataset_item.run(run_name=run_name, run_description=run_description) as span: + dataset_item_trace_id = span.trace_id try: - final_state = sync_run_evaluation_on_item(dataset_item, langfuse_session_id=trace_id) # type: ignore + final_state = sync_run_evaluation_on_item(dataset_item, langfuse_session_id=span.trace_id) # type: ignore except Exception as e: logger.exception(f"Error running evaluation: {e}") @@ -1039,34 +1043,34 @@ def run_autofix_evaluation_on_item( if root_cause_scores: root_cause_score, root_cause_verdict, root_cause_helpful = root_cause_scores - langfuse.score( + langfuse.create_score( trace_id=dataset_item_trace_id, name=make_score_name( model=scoring_model, n_panel=scoring_n_panel, name="rc_is_correct" ), value=1 if root_cause_verdict else 0, ) - langfuse.score( + langfuse.create_score( trace_id=dataset_item_trace_id, name=make_score_name( model=scoring_model, n_panel=scoring_n_panel, name="rc_is_helpful" ), value=1 if root_cause_helpful else 0, ) - langfuse.score( + langfuse.create_score( trace_id=dataset_item_trace_id, name=make_score_name( model=scoring_model, n_panel=scoring_n_panel, name="rc_error_weighted_score" ), value=root_cause_score, ) - langfuse.score( + langfuse.create_score( trace_id=dataset_item_trace_id, name=make_score_name(model=scoring_model, n_panel=scoring_n_panel, name="rc_score"), value=root_cause_score, ) else: - langfuse.score( + langfuse.create_score( trace_id=dataset_item_trace_id, name=make_score_name( model=scoring_model, n_panel=scoring_n_panel, name="rc_error_weighted_score" @@ -1088,14 +1092,14 @@ def run_autofix_evaluation_on_item( if solution_scores: mean_score, verdict = solution_scores - langfuse.score( + langfuse.create_score( trace_id=dataset_item_trace_id, name=make_score_name( model=scoring_model, n_panel=scoring_n_panel, name="solution_is_fixed" ), value=1 if verdict else 0, ) - langfuse.score( + langfuse.create_score( trace_id=dataset_item_trace_id, name=make_score_name( model=scoring_model, @@ -1104,7 +1108,7 @@ def run_autofix_evaluation_on_item( ), value=mean_score, ) - langfuse.score( + langfuse.create_score( trace_id=dataset_item_trace_id, name=make_score_name( model=scoring_model, n_panel=scoring_n_panel, name="solution_score" @@ -1112,7 +1116,7 @@ def run_autofix_evaluation_on_item( value=mean_score, ) else: - langfuse.score( + langfuse.create_score( trace_id=dataset_item_trace_id, name=make_score_name( model=scoring_model, @@ -1136,14 +1140,14 @@ def run_autofix_evaluation_on_item( if coding_scores: mean_correctness_score, mean_conciseness_score = coding_scores - langfuse.score( + langfuse.create_score( trace_id=dataset_item_trace_id, name=make_score_name( model=scoring_model, n_panel=scoring_n_panel, name="code_correctness_score" ), value=mean_correctness_score, ) - langfuse.score( + langfuse.create_score( trace_id=dataset_item_trace_id, name=make_score_name( model=scoring_model, n_panel=scoring_n_panel, name="code_conciseness_score" diff --git a/src/seer/automation/autofix/tools/read_file_contents.py b/src/seer/automation/autofix/tools/read_file_contents.py index 6c5a5093..8caa0216 100644 --- a/src/seer/automation/autofix/tools/read_file_contents.py +++ b/src/seer/automation/autofix/tools/read_file_contents.py @@ -2,7 +2,7 @@ import os import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe logger = logging.getLogger(__name__) diff --git a/src/seer/automation/autofix/tools/ripgrep_search.py b/src/seer/automation/autofix/tools/ripgrep_search.py index 28502658..dac11b2b 100644 --- a/src/seer/automation/autofix/tools/ripgrep_search.py +++ b/src/seer/automation/autofix/tools/ripgrep_search.py @@ -3,7 +3,7 @@ from pathlib import Path import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe MAX_RIPGREP_TIMEOUT_SECONDS = 45 MAX_RIPGREP_LINE_CHARACTER_LENGTH = 1024 diff --git a/src/seer/automation/autofix/tools/tools.py b/src/seer/automation/autofix/tools/tools.py index 3c2318d7..f62e1747 100644 --- a/src/seer/automation/autofix/tools/tools.py +++ b/src/seer/automation/autofix/tools/tools.py @@ -7,7 +7,7 @@ from typing import Any, Set, cast import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from seer.automation.agent.client import GeminiProvider, LlmClient from seer.automation.agent.tools import ClaudeTool, FunctionTool @@ -786,7 +786,7 @@ def handle_claude_tools(self, **kwargs: Any) -> str: handler = command_handlers.get(command) if handler: - return handler( + return handler( # type: ignore[operator] kwargs, repo_name, path, diff --git a/src/seer/automation/autofix/utils.py b/src/seer/automation/autofix/utils.py index d396bda5..995bf162 100644 --- a/src/seer/automation/autofix/utils.py +++ b/src/seer/automation/autofix/utils.py @@ -1,7 +1,7 @@ import random import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from rapidfuzz import fuzz, process VALID_RANDOM_SUFFIX_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" diff --git a/src/seer/automation/codebase/base_repo_client.py b/src/seer/automation/codebase/base_repo_client.py new file mode 100644 index 00000000..add02631 --- /dev/null +++ b/src/seer/automation/codebase/base_repo_client.py @@ -0,0 +1,642 @@ +""" +Abstract base class for repository clients. + +This module defines the interface that all repository provider implementations +(GitHub, GitLab, etc.) must implement. +""" + +import functools +import logging +import os +import shutil +import tarfile +import tempfile +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum +from typing import Any + +import requests + +from seer.automation.codebase.utils import get_all_supported_extensions +from seer.automation.models import FileChange, FilePatch, RepoDefinition + +logger = logging.getLogger(__name__) + + +class RepoClientType(str, Enum): + READ = "read" + WRITE = "write" + CODECOV_UNIT_TEST = "codecov_unit_test" + CODECOV_PR_REVIEW = "codecov_pr_review" + CODECOV_PR_CLOSED = "codecov_pr_closed" + + +@dataclass +class PullRequestResult: + """ + Common return type for PR/MR creation across providers. + Normalizes the differences between GitHub PR and GitLab MR attributes. + """ + + number: int # GitHub: pr.number, GitLab: mr.iid + html_url: str # GitHub: pr.html_url, GitLab: mr.web_url + id: int # GitHub: pr.id, GitLab: mr.id + head_ref: str # Branch name + head_sha: str | None = None # Commit SHA of the head + + +@dataclass +class BranchRefResult: + """ + Common return type for branch creation across providers. + """ + + ref: str # Full ref path (e.g., "refs/heads/branch-name") + sha: str # Commit SHA + name: str # Branch name only + + @property + def object_sha(self) -> str: + """Alias for sha for compatibility with GitHub's branch_ref.object.sha""" + return self.sha + + +class BaseRepoClient(ABC): + """ + Abstract base class defining the interface for repository clients. + + All repository provider implementations (GitHub, GitLab, etc.) must inherit + from this class and implement all abstract methods. + """ + + provider: str + repo_owner: str + repo_name: str + repo_external_id: str + base_commit_sha: str + base_branch: str + repo_definition: RepoDefinition + + # Providers that this base supports - subclasses should override + supported_providers: list[str] = [] + + def __init__(self, repo_definition: RepoDefinition): + """ + Initialize the base repo client. + + Args: + repo_definition: Definition of the repository to work with. + """ + self.provider = repo_definition.provider + self.repo_owner = repo_definition.owner + self.repo_name = repo_definition.name + self.repo_external_id = repo_definition.external_id + self.repo_definition = repo_definition + + # Set up caching for expensive operations + self.get_valid_file_paths = functools.lru_cache(maxsize=8)(self._get_valid_file_paths) + self.get_commit_history = functools.lru_cache(maxsize=16)(self._get_commit_history) + self.get_commit_patch_for_file = functools.lru_cache(maxsize=16)( + self._get_commit_patch_for_file + ) + + @property + def repo_full_name(self) -> str: + """Return the full repository name (owner/name).""" + return f"{self.repo_owner}/{self.repo_name}" + + @classmethod + @abstractmethod + def from_repo_definition( + cls, repo_def: RepoDefinition, type: RepoClientType + ) -> "BaseRepoClient": + """ + Factory method to create a repo client from a repo definition. + + Args: + repo_def: Definition of the repository. + type: Type of client access needed (read, write, etc.). + + Returns: + An instance of the repo client. + """ + pass + + @staticmethod + @abstractmethod + def check_repo_write_access(repo: RepoDefinition) -> bool | None: + """ + Check if the client has write access to the repository. + + Args: + repo: Repository definition to check. + + Returns: + True if write access is available, False if not, None if unable to check. + """ + pass + + @staticmethod + @abstractmethod + def check_repo_read_access(repo: RepoDefinition) -> bool | None: + """ + Check if the client has read access to the repository. + + Args: + repo: Repository definition to check. + + Returns: + True if read access is available, False if not, None if unable to check. + """ + pass + + @abstractmethod + def get_default_branch(self) -> str: + """ + Get the default branch name for the repository. + + Returns: + The name of the default branch (e.g., "main", "master"). + """ + pass + + @abstractmethod + def get_branch_head_sha(self, branch: str) -> str: + """ + Get the head commit SHA for a branch. + + Args: + branch: Branch name. + + Returns: + The SHA of the head commit on the branch. + """ + pass + + @abstractmethod + def get_file_content(self, path: str, sha: str | None = None) -> tuple[str | None, str]: + """ + Get the content of a file at a specific commit. + + Args: + path: Path to the file in the repository. + sha: Commit SHA to get the file from. Defaults to base_commit_sha. + + Returns: + Tuple of (file_content, encoding). Content is None if file doesn't exist. + """ + pass + + @abstractmethod + def _get_valid_file_paths(self, commit_sha: str | None = None) -> set[str]: + """ + Get all valid file paths in the repository at a specific commit. + This is the uncached implementation - use get_valid_file_paths() instead. + + Args: + commit_sha: Commit SHA to get files from. Defaults to base_commit_sha. + + Returns: + Set of valid file paths. + """ + pass + + @abstractmethod + def load_repo_to_tmp_dir(self, sha: str | None = None) -> tuple[str, str]: + """ + Download and extract the repository to a temporary directory. + + Args: + sha: Commit SHA to download. Defaults to base_commit_sha. + + Returns: + Tuple of (tmp_dir, tmp_repo_dir) paths. + """ + pass + + @abstractmethod + def create_branch_from_changes( + self, + *, + pr_title: str, + file_patches: list[FilePatch] | None = None, + file_changes: list[FileChange] | None = None, + branch_name: str | None = None, + from_base_sha: bool = False, + ) -> BranchRefResult | None: + """ + Create a new branch with the specified file changes. + + Args: + pr_title: Title for the PR (used to generate branch name). + file_patches: List of file patches to apply. + file_changes: List of file changes to apply. + branch_name: Optional specific branch name. + from_base_sha: If True, create from base_commit_sha instead of branch head. + + Returns: + BranchRefResult if successful, None if no changes were made. + """ + pass + + @abstractmethod + def create_pr_from_branch( + self, + branch: BranchRefResult, + title: str, + description: str, + provided_base: str | None = None, + ) -> PullRequestResult: + """ + Create a pull/merge request from a branch. + + Args: + branch: Branch reference to create PR from. + title: PR title. + description: PR description/body. + provided_base: Optional base branch to merge into. + + Returns: + PullRequestResult with PR details. + """ + pass + + @abstractmethod + def post_issue_comment(self, pr_url: str, comment: str) -> str: + """ + Post a comment on a PR/MR. + + Args: + pr_url: URL of the PR/MR. + comment: Comment text to post. + + Returns: + URL of the created comment. + """ + pass + + @abstractmethod + def get_branch_ref(self, branch_name: str) -> BranchRefResult | None: + """ + Get a branch reference by name. + + Args: + branch_name: Name of the branch. + + Returns: + BranchRefResult if branch exists, None otherwise. + """ + pass + + # GitHub Copilot-specific methods with default no-op implementations + # These are only used by GitHub and can be overridden by subclasses + + def comment_root_cause_on_pr_for_copilot( + self, pr_url: str, run_id: int, issue_id: int, comment: str + ) -> None: + """ + Post a root cause comment on a PR for GitHub Copilot integration. + This is a GitHub-specific feature; other providers can override or ignore. + + Args: + pr_url: URL of the PR. + run_id: Autofix run ID. + issue_id: Issue ID. + comment: Comment text. + """ + pass + + def comment_pr_generated_for_copilot( + self, pr_to_comment_on_url: str, new_pr_url: str, run_id: int + ) -> None: + """ + Post a comment that a fix PR was generated for GitHub Copilot integration. + This is a GitHub-specific feature; other providers can override or ignore. + + Args: + pr_to_comment_on_url: URL of the original PR to comment on. + new_pr_url: URL of the newly generated PR. + run_id: Autofix run ID. + """ + pass + + # Common methods with default implementations + + def _get_commit_history( + self, path: str, sha: str | None = None, autocorrect: bool = False, max_commits: int = 10 + ) -> list[str]: + """ + Get commit history for a file. + + Args: + path: File path to get history for. + sha: Commit SHA to start from. + autocorrect: Whether to attempt path autocorrection. + max_commits: Maximum number of commits to return. + + Returns: + List of formatted commit history strings. + """ + # Default implementation returns empty - subclasses should override + return [] + + def _get_commit_patch_for_file( + self, path: str, commit_sha: str, autocorrect: bool = False + ) -> str | None: + """ + Get the patch for a file in a specific commit. + + Args: + path: File path to get patch for. + commit_sha: Commit SHA. + autocorrect: Whether to attempt path autocorrection. + + Returns: + Patch string or None if not found. + """ + # Default implementation returns None - subclasses should override + return None + + def does_file_exist(self, path: str, sha: str | None = None) -> bool: + """ + Check if a file exists in the repository. + + Args: + path: Path to check. + sha: Commit SHA to check at. Defaults to base_commit_sha. + + Returns: + True if file exists, False otherwise. + """ + if sha is None: + sha = self.base_commit_sha + + all_files = self.get_valid_file_paths(sha) + normalized_path = path.lstrip("./").lstrip("/") + return normalized_path in all_files + + def get_file_url( + self, file_path: str, start_line: int | None = None, end_line: int | None = None + ) -> str: + """ + Get a URL to view a file in the repository. + + Args: + file_path: Path to the file. + start_line: Optional starting line number. + end_line: Optional ending line number. + + Returns: + URL to view the file. + """ + # Default implementation for GitHub - subclasses should override + url = f"https://github.com/{self.repo_full_name}/blob/{self.base_commit_sha}/{file_path}" + if start_line: + url += f"#L{start_line}" + if start_line and end_line: + url += f"-L{end_line}" + elif end_line: + url += f"#L{end_line}" + return url + + def get_commit_url(self, commit_sha: str) -> str: + """ + Get a URL to view a commit. + + Args: + commit_sha: The commit SHA. + + Returns: + URL to view the commit. + """ + # Default implementation for GitHub - subclasses should override + return f"https://github.com/{self.repo_full_name}/commit/{commit_sha}" + + def _load_archive_to_dir( + self, archive_url: str, sha: str, auth_headers: dict[str, str] | None = None + ) -> tuple[str, str]: + """ + Common implementation for loading a repository archive to a temporary directory. + + Args: + archive_url: URL to download the archive from. + sha: Commit SHA for naming. + auth_headers: Optional authentication headers. + + Returns: + Tuple of (tmp_dir, tmp_repo_dir) paths. + """ + tmp_dir = tempfile.mkdtemp(prefix=f"{self.repo_owner}-{self.repo_name}_{sha}") + tmp_repo_dir = os.path.join(tmp_dir, "repo") + + logger.debug(f"Loading repository to {tmp_repo_dir}") + + os.makedirs(tmp_repo_dir, exist_ok=True) + + # Clean the directory + for root, dirs, files in os.walk(tmp_repo_dir, topdown=False): + for name in files: + os.remove(os.path.join(root, name)) + for name in dirs: + os.rmdir(os.path.join(root, name)) + + tarfile_path = os.path.join(tmp_dir, f"{sha}.tar.gz") + + response = requests.get(archive_url, stream=True, headers=auth_headers, timeout=30) + if response.status_code == 200: + with open(tarfile_path, "wb") as f: + f.write(response.content) + else: + logger.error( + f"Failed to get tarball url for {archive_url}. " + "Please check if the repository exists and the provided token is valid." + ) + logger.error( + f"Response status code: {response.status_code}, response text: {response.text}" + ) + raise Exception( + f"Failed to get tarball url for {archive_url}. " + "Please check if the repository exists and the provided token is valid." + ) + + # Extract tarball into the output directory with path traversal protection + def _safe_extractall(tar: tarfile.TarFile, path: str) -> None: + """Safely extract tar archive, blocking path traversal attacks.""" + base = os.path.realpath(path) + for member in tar.getmembers(): + member_path = os.path.realpath(os.path.join(path, member.name)) + if not member_path.startswith(base + os.sep) and member_path != base: + raise Exception(f"Blocked path traversal attempt in tar archive: {member.name}") + tar.extractall(path=path) + + with tarfile.open(tarfile_path, "r:gz") as tar: + _safe_extractall(tar, tmp_repo_dir) + extracted_folders = [ + name + for name in os.listdir(tmp_repo_dir) + if os.path.isdir(os.path.join(tmp_repo_dir, name)) + ] + if extracted_folders: + root_folder = extracted_folders[0] + root_folder_path = os.path.join(tmp_repo_dir, root_folder) + for item in os.listdir(root_folder_path): + s = os.path.join(root_folder_path, item) + d = os.path.join(tmp_repo_dir, item) + if os.path.isdir(s): + shutil.move(s, d) + else: + if not os.path.islink(s): + shutil.copy2(s, d) + + shutil.rmtree(root_folder_path) + + return tmp_dir, tmp_repo_dir + + def _build_file_tree_string( + self, files: list[dict], only_immediate_children_of_path: str | None = None + ) -> str: + """ + Build a tree representation of files to save tokens when many files share directories. + + Args: + files: List of dictionaries with 'path' and 'status' keys. + only_immediate_children_of_path: If provided, only include immediate children of this path. + + Returns: + A string representation of the file tree. + """ + if not files: + return "No files changed" + + if only_immediate_children_of_path is not None: + only_immediate_children_of_path = only_immediate_children_of_path.rstrip("/") + + # Build a nested dictionary structure representing the file tree + tree: dict = {} + for file in files: + path = file["path"] + status = file["status"] + + parts = path.split("/") + current = tree + for i, part in enumerate(parts): + if i == len(parts) - 1: + current[part] = {"__status__": status} + else: + if part not in current: + current[part] = {} + current = current[part] + + lines: list[str] = [] + + def _build_tree( + node: dict, + previous_parts: list[str] | None = None, + prefix: str = "", + is_last: bool = True, + is_root: bool = True, + ) -> None: + if previous_parts is None: + previous_parts = [] + items = list(node.items()) + + for i, (key, value) in enumerate(sorted(items)): + if key == "__status__": + continue + + is_last_item = i == len(items) - 1 or (i == len(items) - 2 and "__status__" in node) + + if is_root: + current_prefix = "" + next_prefix = "" + else: + current_prefix = prefix + ("└── " if is_last else "├── ") + next_prefix = prefix + (" " if is_last else "│ ") + + if "__status__" in value: + if ( + only_immediate_children_of_path is not None + and only_immediate_children_of_path != "/".join(previous_parts) + ): + continue + + status = value["__status__"] + status_str = f" ({status})" if status else "" + lines.append(f"{current_prefix}{key}{status_str}") + else: + if ( + only_immediate_children_of_path is None + or only_immediate_children_of_path.startswith( + "/".join(previous_parts + [key]) + ) + ): + lines.append(f"{current_prefix}{key}/") + _build_tree(value, previous_parts + [key], next_prefix, is_last_item, False) + elif ( + only_immediate_children_of_path is not None + and only_immediate_children_of_path == "/".join(previous_parts) + ): + lines.append(f"{current_prefix}{key}/") + + _build_tree(tree) + return "\n".join(lines) + + def _get_valid_file_paths_from_tree( + self, tree_items: list[Any], path_attr: str = "path", type_attr: str = "type" + ) -> set[str]: + """ + Extract valid file paths from a tree structure. + + Args: + tree_items: List of tree items from the provider API. + path_attr: Attribute name for the file path. + type_attr: Attribute name for the item type. + + Returns: + Set of valid file paths. + """ + valid_file_paths: set[str] = set() + valid_file_extensions = get_all_supported_extensions() + + for item in tree_items: + path = getattr(item, path_attr, None) or item.get(path_attr) + item_type = getattr(item, type_attr, None) or item.get(type_attr) + size = getattr(item, "size", None) or item.get("size", 0) + + if ( + item_type == "blob" + and path + and any(path.endswith(ext) for ext in valid_file_extensions) + and (size or 0) <= 1024 * 1024 # 1MB limit + ): + valid_file_paths.add(path) + + return valid_file_paths + + +def get_repo_client_for_provider( + repos: list[RepoDefinition], + repo_name: str | None = None, + repo_external_id: str | None = None, + type: RepoClientType = RepoClientType.READ, +) -> BaseRepoClient: + """ + Gets a repo client for a given repo, routing to the appropriate provider implementation. + + Args: + repos: List of repository definitions available. + repo_name: Optional repository name to select. + repo_external_id: Optional external ID to select. + type: Type of client access needed. + + Returns: + An appropriate repo client instance for the provider. + + Raises: + AgentError: If the repository is not found. + """ + # Import here to avoid circular imports + from seer.automation.codebase.repo_client import get_repo_client + + return get_repo_client(repos, repo_name, repo_external_id, type) diff --git a/src/seer/automation/codebase/gitlab_repo_client.py b/src/seer/automation/codebase/gitlab_repo_client.py new file mode 100644 index 00000000..c76e9446 --- /dev/null +++ b/src/seer/automation/codebase/gitlab_repo_client.py @@ -0,0 +1,946 @@ +""" +GitLab repository client implementation. + +This module provides GitLab-specific implementation of the BaseRepoClient interface, +enabling Merge Request creation and repository operations for GitLab repositories. +""" + +import functools +import logging +import os +import tempfile +from typing import Any + +import gitlab +import sentry_sdk +from gitlab.v4.objects import Project + +from seer.automation.autofix.utils import generate_random_string, sanitize_branch_name +from seer.automation.codebase.base_repo_client import ( + BaseRepoClient, + BranchRefResult, + PullRequestResult, + RepoClientType, +) +from seer.automation.codebase.models import GitLabMrReviewComment +from seer.automation.codebase.utils import get_all_supported_extensions +from seer.automation.models import FileChange, FilePatch, InitializationError, RepoDefinition +from seer.automation.utils import decode_raw_data +from seer.configuration import AppConfig +from seer.dependency_injection import inject, injected + +logger = logging.getLogger(__name__) + + +@inject +def get_gitlab_token(config: AppConfig = injected) -> str | None: + """Get the GitLab API token from configuration.""" + return config.GITLAB_TOKEN + + +@inject +def get_gitlab_instance_url(config: AppConfig = injected) -> str: + """Get the GitLab instance URL from configuration.""" + return config.GITLAB_INSTANCE_URL + + +class GitLabRepoClient(BaseRepoClient): + """ + GitLab-specific implementation of the repository client. + Provides access to GitLab repositories via the GitLab API. + """ + + gitlab_client: gitlab.Gitlab + project: Project + + supported_providers = ["gitlab"] + + @sentry_sdk.trace + def __init__(self, token: str | None, repo_definition: RepoDefinition): + """ + Initialize the GitLab repo client. + + Args: + token: GitLab API token for authentication. + repo_definition: Definition of the repository to work with. + """ + if repo_definition.provider != "gitlab": + raise InitializationError( + f"GitLabRepoClient only supports 'gitlab' provider, got: {repo_definition.provider}" + ) + + if not token: + raise InitializationError( + "No GitLab token provided. Please set GITLAB_TOKEN in configuration." + ) + + instance_url = get_gitlab_instance_url() + self.gitlab_client = gitlab.Gitlab(instance_url, private_token=token, timeout=30) + + # Get project - GitLab supports both numeric IDs and path-based IDs (owner/name) + try: + with sentry_sdk.start_span( + op="gitlab_repo_client.project.get", description=repo_definition.full_name + ): + # Try by full name first + self.project = self.gitlab_client.projects.get(repo_definition.full_name) + except gitlab.exceptions.GitlabGetError: + logger.warning( + f"Could not get project by full name {repo_definition.full_name}, " + f"trying by external_id {repo_definition.external_id}" + ) + try: + with sentry_sdk.start_span( + op="gitlab_repo_client.project.get_by_id", + description=repo_definition.external_id, + ): + self.project = self.gitlab_client.projects.get(repo_definition.external_id) + except gitlab.exceptions.GitlabGetError as e: + logger.exception( + f"Error getting GitLab project {repo_definition.full_name} " + f"or {repo_definition.external_id}" + ) + raise e + + self.provider = repo_definition.provider + self.repo_owner = repo_definition.owner + self.repo_name = repo_definition.name + self.repo_external_id = repo_definition.external_id + self.base_branch = repo_definition.branch_name or self.get_default_branch() + self.base_commit_sha = repo_definition.base_commit_sha or self.get_branch_head_sha( + self.base_branch + ) + self.repo_definition = repo_definition + + # Set up caching for expensive operations + self.get_valid_file_paths = functools.lru_cache(maxsize=8)(self._get_valid_file_paths) + self.get_commit_history = functools.lru_cache(maxsize=16)(self._get_commit_history) + self.get_commit_patch_for_file = functools.lru_cache(maxsize=16)( + self._get_commit_patch_for_file + ) + + @staticmethod + def check_repo_write_access(repo: RepoDefinition) -> bool | None: + """ + Check if the client has write access to the repository. + + Args: + repo: Repository definition to check. + + Returns: + True if write access is available, False if not, None if unable to check. + """ + token = get_gitlab_token() + if not token: + return None + + try: + instance_url = get_gitlab_instance_url() + gl = gitlab.Gitlab(instance_url, private_token=token, timeout=30) + project = gl.projects.get(repo.full_name) + + # Check access level - Developer (30) or higher can push + # Maintainer (40) or higher can create MRs to protected branches + access_level = project.permissions.get("project_access", {}).get("access_level", 0) + group_access = project.permissions.get("group_access", {}).get("access_level", 0) + max_access = max(access_level or 0, group_access or 0) + + # Developer level (30) or higher has write access + return max_access >= 30 + except Exception: + logger.exception(f"Error checking GitLab write access for {repo.full_name}") + return None + + @staticmethod + def check_repo_read_access(repo: RepoDefinition) -> bool | None: + """ + Check if the client has read access to the repository. + + Args: + repo: Repository definition to check. + + Returns: + True if read access is available, False if not, None if unable to check. + """ + token = get_gitlab_token() + if not token: + return None + + try: + instance_url = get_gitlab_instance_url() + gl = gitlab.Gitlab(instance_url, private_token=token, timeout=30) + project = gl.projects.get(repo.full_name) + + # If we can get the project, we have at least read access + return project is not None + except gitlab.exceptions.GitlabGetError: + return False + except Exception: + logger.exception(f"Error checking GitLab read access for {repo.full_name}") + return None + + @classmethod + @functools.lru_cache(maxsize=8) + def from_repo_definition(cls, repo_def: RepoDefinition, type: RepoClientType): + """ + Factory method to create a GitLab repo client from a repo definition. + + Args: + repo_def: Definition of the repository. + type: Type of client access needed (read, write, etc.). + + Returns: + An instance of GitLabRepoClient. + """ + # For now, we use the same token for all access types + # In the future, we might want to support different tokens for different access levels + token = get_gitlab_token() + return cls(token, repo_def) + + def get_default_branch(self) -> str: + """ + Get the default branch name for the repository. + + Returns: + The name of the default branch. + """ + return self.project.default_branch + + def get_branch_head_sha(self, branch: str) -> str: + """ + Get the head commit SHA for a branch. + + Args: + branch: Branch name. + + Returns: + The SHA of the head commit on the branch. + """ + branch_obj = self.project.branches.get(branch) + return branch_obj.commit["id"] + + def get_file_content(self, path: str, sha: str | None = None) -> tuple[str | None, str]: + """ + Get the content of a file at a specific commit. + + Args: + path: Path to the file in the repository. + sha: Commit SHA to get the file from. Defaults to base_commit_sha. + + Returns: + Tuple of (file_content, encoding). Content is None if file doesn't exist. + """ + logger.debug(f"Getting file contents for {path} in {self.repo_full_name} on sha {sha}") + if sha is None: + sha = self.base_commit_sha + + # Normalize the path by removing leading slashes + if path.startswith("/"): + path = path[1:] + if path.startswith("./"): + path = path[2:] + + try: + file_obj = self.project.files.get(file_path=path, ref=sha) + content = file_obj.decode() + + # decode() returns bytes, we need to decode to string + return decode_raw_data(content) + except gitlab.exceptions.GitlabGetError as e: + if e.response_code == 404: + logger.warning(f"File not found: {path} at ref {sha}") + return None, "utf-8" + logger.exception(f"Error getting file contents: {e}") + return None, "utf-8" + except Exception as e: + logger.exception(f"Error getting file contents: {e}") + return None, "utf-8" + + def _get_valid_file_paths(self, commit_sha: str | None = None) -> set[str]: + """ + Get all valid file paths in the repository at a specific commit. + + Args: + commit_sha: Commit SHA to get files from. Defaults to base_commit_sha. + + Returns: + Set of valid file paths. + """ + if commit_sha is None: + commit_sha = self.base_commit_sha + + valid_file_paths: set[str] = set() + valid_file_extensions = get_all_supported_extensions() + + # GitLab's repository_tree returns items with pagination + # We need to iterate through all pages + try: + tree = self.project.repository_tree(ref=commit_sha, recursive=True, get_all=True) + + for item in tree: + if item["type"] == "blob" and any( + item["path"].endswith(ext) for ext in valid_file_extensions + ): + # GitLab doesn't return file size in repository_tree + # We'll include all files and filter by size when reading + valid_file_paths.add(item["path"]) + + except gitlab.exceptions.GitlabGetError as e: + logger.exception(f"Error getting repository tree: {e}") + + return valid_file_paths + + @sentry_sdk.trace + def load_repo_to_tmp_dir(self, sha: str | None = None) -> tuple[str, str]: + """ + Download and extract the repository to a temporary directory. + + Args: + sha: Commit SHA to download. Defaults to base_commit_sha. + + Returns: + Tuple of (tmp_dir, tmp_repo_dir) paths. + """ + sha = sha or self.base_commit_sha + + # Create temp directory + tmp_dir = tempfile.mkdtemp(prefix=f"{self.repo_owner}-{self.repo_name}_{sha}") + tmp_repo_dir = os.path.join(tmp_dir, "repo") + + logger.debug(f"Loading repository to {tmp_repo_dir}") + + os.makedirs(tmp_repo_dir, exist_ok=True) + + # Get archive from GitLab + tarfile_path = os.path.join(tmp_dir, f"{sha}.tar.gz") + + try: + # GitLab's repository_archive returns the archive content directly + archive = self.project.repository_archive(sha=sha, format="tar.gz") + + with open(tarfile_path, "wb") as f: + f.write(archive) + + except gitlab.exceptions.GitlabGetError as e: + logger.error(f"Failed to get archive for {self.repo_full_name} at {sha}: {e}") + raise Exception( + f"Failed to get archive for {self.repo_full_name} at {sha}. " + "Please check if the repository exists and the provided token is valid." + ) + + # Extract tarball - use safe extraction with path traversal protection + import shutil + import tarfile + + def _safe_extractall(tar: tarfile.TarFile, path: str) -> None: + """Safely extract tar archive, blocking path traversal attacks.""" + base = os.path.realpath(path) + for member in tar.getmembers(): + member_path = os.path.realpath(os.path.join(path, member.name)) + if not member_path.startswith(base + os.sep) and member_path != base: + raise Exception(f"Blocked path traversal attempt in tar archive: {member.name}") + tar.extractall(path=path) + + with tarfile.open(tarfile_path, "r:gz") as tar: + _safe_extractall(tar, tmp_repo_dir) + extracted_folders = [ + name + for name in os.listdir(tmp_repo_dir) + if os.path.isdir(os.path.join(tmp_repo_dir, name)) + ] + if extracted_folders: + root_folder = extracted_folders[0] + root_folder_path = os.path.join(tmp_repo_dir, root_folder) + for item in os.listdir(root_folder_path): + s = os.path.join(root_folder_path, item) + d = os.path.join(tmp_repo_dir, item) + if os.path.isdir(s): + shutil.move(s, d) + else: + if not os.path.islink(s): + shutil.copy2(s, d) + + shutil.rmtree(root_folder_path) + + return tmp_dir, tmp_repo_dir + + def _create_branch(self, branch_name: str, from_base_sha: bool = False) -> dict[str, Any]: + """ + Create a new branch in the repository. + + Args: + branch_name: Name of the branch to create. + from_base_sha: If True, create from base_commit_sha instead of branch head. + + Returns: + Branch data dictionary. + """ + ref = self.base_commit_sha if from_base_sha else self.get_branch_head_sha(self.base_branch) + + branch = self.project.branches.create({"branch": branch_name, "ref": ref}) + return branch.attributes + + def get_branch_ref(self, branch_name: str) -> BranchRefResult | None: + """ + Get a branch reference by name. + + Args: + branch_name: Name of the branch. + + Returns: + BranchRefResult if branch exists, None otherwise. + """ + try: + branch = self.project.branches.get(branch_name) + return BranchRefResult( + ref=f"refs/heads/{branch_name}", + sha=branch.commit["id"], + name=branch_name, + ) + except gitlab.exceptions.GitlabGetError as e: + if e.response_code == 404: + return None + raise e + + def create_branch_from_changes( + self, + *, + pr_title: str, + file_patches: list[FilePatch] | None = None, + file_changes: list[FileChange] | None = None, + branch_name: str | None = None, + from_base_sha: bool = False, + ) -> BranchRefResult | None: + """ + Create a new branch with the specified file changes. + + Uses GitLab's commits API to create a commit with file actions. + + Args: + pr_title: Title for the PR (used to generate branch name). + file_patches: List of file patches to apply. + file_changes: List of file changes to apply. + branch_name: Optional specific branch name. + from_base_sha: If True, create from base_commit_sha instead of branch head. + + Returns: + BranchRefResult if successful, None if no changes were made. + """ + if not file_patches and not file_changes: + raise ValueError("Either file_patches or file_changes must be provided") + + new_branch_name = sanitize_branch_name(branch_name or pr_title) + + # Create the branch + try: + self._create_branch(new_branch_name, from_base_sha) + except gitlab.exceptions.GitlabCreateError as e: + # Branch already exists, add random suffix + if "already exists" in str(e).lower() or e.response_code == 400: + new_branch_name = f"{new_branch_name}-{generate_random_string(n=6)}" + self._create_branch(new_branch_name, from_base_sha) + else: + raise e + + # Build commit actions + actions = [] + branch_ref = new_branch_name + + if file_patches: + for patch in file_patches: + action = self._build_commit_action_for_patch(patch, branch_ref) + if action: + actions.append(action) + elif file_changes: + for change in file_changes: + action = self._build_commit_action_for_change(change, branch_ref) + if action: + actions.append(action) + + if not actions: + # No valid actions, delete the branch + try: + self.project.branches.delete(new_branch_name) + except gitlab.exceptions.GitlabDeleteError: + logger.warning(f"Failed to delete branch {new_branch_name}") + return None + + # Create commit with actions + try: + commit_data = { + "branch": new_branch_name, + "commit_message": pr_title, + "actions": actions, + } + commit = self.project.commits.create(commit_data) + + # Verify commit was created and has changes + base_sha = self.get_branch_head_sha(self.base_branch) + try: + comparison = self.project.repository_compare(base_sha, commit.id) + # repository_compare returns dict, but typing is Response | dict + comp_dict = comparison if isinstance(comparison, dict) else {} + if not comp_dict.get("commits") and not comp_dict.get("diffs"): + # No changes, delete the branch + try: + self.project.branches.delete(new_branch_name) + except gitlab.exceptions.GitlabDeleteError: + pass + sentry_sdk.capture_message( + "Failed to create branch from changes - no changes detected" + ) + return None + except gitlab.exceptions.GitlabGetError: + # Comparison failed but commit was created, proceed + pass + + return BranchRefResult( + ref=f"refs/heads/{new_branch_name}", + sha=commit.id, + name=new_branch_name, + ) + + except gitlab.exceptions.GitlabCreateError as e: + logger.exception(f"Error creating commit: {e}") + # Clean up branch + try: + self.project.branches.delete(new_branch_name) + except gitlab.exceptions.GitlabDeleteError: + pass + raise e + + def _build_commit_action_for_patch( + self, patch: FilePatch, branch_ref: str + ) -> dict[str, Any] | None: + """ + Build a GitLab commit action from a FilePatch. + + Args: + patch: The file patch to convert. + branch_ref: The branch reference to read existing content from. + + Returns: + A commit action dictionary or None if the action is invalid. + """ + path = patch.path + if path.startswith("/"): + path = path[1:] + if path.startswith("./"): + path = path[2:] + + patch_type = patch.type + action_type: str + if patch_type == "A": # Add/Create + action_type = "create" + elif patch_type == "D": # Delete + action_type = "delete" + else: # M = Modify/Update + action_type = "update" + + # Get existing content for non-create operations + existing_content = None + if action_type != "create": + existing_content, _ = self.get_file_content(path, sha=branch_ref) + + new_content = patch.apply(existing_content) + + if action_type == "delete": + return {"action": "delete", "file_path": path} + + if new_content is None: + return None + + return { + "action": action_type, + "file_path": path, + "content": new_content, + } + + def _build_commit_action_for_change( + self, change: FileChange, branch_ref: str + ) -> dict[str, Any] | None: + """ + Build a GitLab commit action from a FileChange. + + Args: + change: The file change to convert. + branch_ref: The branch reference to read existing content from. + + Returns: + A commit action dictionary or None if the action is invalid. + """ + path = change.path + if path.startswith("/"): + path = path[1:] + if path.startswith("./"): + path = path[2:] + + change_type = change.change_type + if change_type == "create": + action_type = "create" + elif change_type == "delete": + action_type = "delete" + else: + action_type = "update" + + # Get existing content for non-create operations + existing_content = None + if action_type != "create": + existing_content, _ = self.get_file_content(path, sha=branch_ref) + + new_content = change.apply(existing_content) + + if action_type == "delete": + return {"action": "delete", "file_path": path} + + if new_content is None: + return None + + return { + "action": action_type, + "file_path": path, + "content": new_content, + } + + def create_pr_from_branch( + self, + branch: BranchRefResult, + title: str, + description: str, + provided_base: str | None = None, + ) -> PullRequestResult: + """ + Create a Merge Request from a branch. + + Args: + branch: Branch reference to create MR from. + title: MR title. + description: MR description/body. + provided_base: Optional base branch to merge into. + + Returns: + PullRequestResult with MR details. + """ + target_branch = provided_base or self.base_branch or self.get_default_branch() + + # Check for existing MR + existing_mrs = self.project.mergerequests.list( + state="opened", source_branch=branch.name, target_branch=target_branch + ) + + if existing_mrs: + logger.warning( + f"Branch {branch.name} already has an open MR.", + extra={ + "branch_ref": branch.ref, + "title": title, + "description": description, + "provided_base": provided_base, + }, + ) + mr = existing_mrs[0] + return PullRequestResult( + number=mr.iid, + html_url=mr.web_url, + id=mr.id, + head_ref=branch.name, + head_sha=branch.sha, + ) + + # Create MR as draft using "Draft:" prefix + draft_title = f"Draft: {title}" if not title.startswith("Draft:") else title + + try: + mr = self.project.mergerequests.create( + { + "source_branch": branch.name, + "target_branch": target_branch, + "title": draft_title, + "description": description, + } + ) + + return PullRequestResult( + number=mr.iid, + html_url=mr.web_url, + id=mr.id, + head_ref=branch.name, + head_sha=branch.sha, + ) + + except gitlab.exceptions.GitlabCreateError as e: + logger.exception(f"Error creating MR: {e}") + raise e + + def post_issue_comment(self, pr_url: str, comment: str) -> str: + """ + Post a comment on a Merge Request. + + Args: + pr_url: URL of the MR. + comment: Comment text to post. + + Returns: + URL of the created comment. + """ + # Extract MR iid from URL + # URL format: https://gitlab.com/owner/repo/-/merge_requests/123 + mr_iid = int(pr_url.rstrip("/").split("/")[-1]) + + mr = self.project.mergerequests.get(mr_iid) + note = mr.notes.create({"body": comment}) + + # GitLab notes don't have direct URLs, construct one + # Format: https://gitlab.com/owner/repo/-/merge_requests/123#note_456 + return f"{mr.web_url}#note_{note.id}" + + def post_mr_review_comment(self, pr_url: str, comment: GitLabMrReviewComment) -> str: + """ + Create a review comment (discussion) on a GitLab Merge Request. + + Args: + pr_url: URL of the MR. + comment: Comment data including position information. + + Returns: + URL of the created discussion. + """ + mr_iid = int(pr_url.rstrip("/").split("/")[-1]) + mr = self.project.mergerequests.get(mr_iid) + + discussion_data: dict[str, Any] = {"body": comment["body"]} + + # Add position data if provided + if "position" in comment and comment["position"]: + discussion_data["position"] = comment["position"] + + discussion = mr.discussions.create(discussion_data) + return f"{mr.web_url}#note_{discussion.id}" + + def get_file_url( + self, file_path: str, start_line: int | None = None, end_line: int | None = None + ) -> str: + """ + Get a URL to view a file in the repository. + + Args: + file_path: Path to the file. + start_line: Optional starting line number. + end_line: Optional ending line number. + + Returns: + URL to view the file. + """ + instance_url = get_gitlab_instance_url().rstrip("/") + url = f"{instance_url}/{self.repo_full_name}/-/blob/{self.base_commit_sha}/{file_path}" + + if start_line: + url += f"#L{start_line}" + if start_line and end_line: + url += f"-{end_line}" + elif end_line: + url += f"#L{end_line}" + + return url + + def get_commit_url(self, commit_sha: str) -> str: + """ + Get a URL to view a commit. + + Args: + commit_sha: The commit SHA. + + Returns: + URL to view the commit. + """ + instance_url = get_gitlab_instance_url().rstrip("/") + return f"{instance_url}/{self.repo_full_name}/-/commit/{commit_sha}" + + def _autocorrect_path(self, path: str, sha: str | None = None) -> tuple[str, bool]: + """ + Attempts to autocorrect a file path by finding the closest match in the repository. + + Args: + path: The path to autocorrect + sha: The commit SHA to use for finding valid paths + + Returns: + A tuple of (corrected_path, was_autocorrected) + """ + if sha is None: + sha = self.base_commit_sha + + path = path.lstrip("/") + valid_paths = self.get_valid_file_paths(sha) + + # If path is valid, return it unchanged + if path in valid_paths: + return path, False + + # Check for partial matches if no exact match and path is long enough + if len(path) > 3: + path_lower = path.lower() + partial_matches = [ + valid_path for valid_path in valid_paths if path_lower in valid_path.lower() + ] + if partial_matches: + # Sort by length to get closest match (shortest containing path) + closest_match = sorted(partial_matches, key=len)[0] + logger.warning( + f"Path '{path}' not found exactly, using closest match: '{closest_match}'" + ) + return closest_match, True + + # No match found + logger.warning("No matching file found for provided file path", extra={"path": path}) + return path, False + + def _get_commit_history( + self, path: str, sha: str | None = None, autocorrect: bool = False, max_commits: int = 10 + ) -> list[str]: + """ + Get commit history for a file. + + Args: + path: File path to get history for. + sha: Commit SHA to start from. + autocorrect: Whether to attempt path autocorrection. + max_commits: Maximum number of commits to return. + + Returns: + List of formatted commit history strings. + """ + if sha is None: + sha = self.base_commit_sha + + if autocorrect: + path, was_autocorrected = self._autocorrect_path(path, sha) + if not was_autocorrected and path not in self.get_valid_file_paths(sha): + return [] + + try: + commits = self.project.commits.list(ref_name=sha, path=path, per_page=max_commits) + commit_strs = [] + + for commit in commits: + short_sha = commit.id[:7] + message = commit.message + + # Get files touched by this commit + commit_detail = self.project.commits.get(commit.id) + diffs = commit_detail.diff() + + diffs_list = list(diffs) if not isinstance(diffs, list) else diffs + files_touched = [ + {"path": d["new_path"], "status": self._map_diff_status(d)} + for d in diffs_list[:20] + ] + + additional_files_note = "" + if len(diffs_list) > 20: + additional_files_note = ( + f"\n[and {len(diffs_list) - 20} more files were changed...]" + ) + + string = f"""---------------- +{short_sha} - {message} +Files touched: +{self._format_files_touched(files_touched)}{additional_files_note} +""" + commit_strs.append(string) + + return commit_strs + + except gitlab.exceptions.GitlabGetError as e: + logger.exception(f"Error getting commit history: {e}") + return [] + + def _map_diff_status(self, diff: dict) -> str: + """Map GitLab diff to status string.""" + if diff.get("new_file"): + return "added" + elif diff.get("deleted_file"): + return "removed" + elif diff.get("renamed_file"): + return "renamed" + else: + return "modified" + + def _format_files_touched(self, files: list[dict]) -> str: + """Format files touched list.""" + if not files: + return "No files changed" + return "\n".join([f" {f['path']} ({f['status']})" for f in files]) + + def _get_commit_patch_for_file( + self, path: str, commit_sha: str, autocorrect: bool = False + ) -> str | None: + """ + Get the patch for a file in a specific commit. + + Args: + path: File path to get patch for. + commit_sha: Commit SHA. + autocorrect: Whether to attempt path autocorrection. + + Returns: + Patch string or None if not found. + """ + if autocorrect: + path, was_autocorrected = self._autocorrect_path(path, commit_sha) + if not was_autocorrected and path not in self.get_valid_file_paths(commit_sha): + return None + + try: + commit = self.project.commits.get(commit_sha) + diffs = commit.diff() + + for diff in diffs: + if diff["new_path"] == path or diff["old_path"] == path: + return diff.get("diff") + + return None + + except gitlab.exceptions.GitlabGetError as e: + logger.exception(f"Error getting commit patch: {e}") + return None + + def get_mr_diff_content(self, mr_url: str) -> str: + """ + Get the diff content of a Merge Request. + + Args: + mr_url: URL of the MR. + + Returns: + The diff content as a string. + """ + mr_iid = int(mr_url.rstrip("/").split("/")[-1]) + mr = self.project.mergerequests.get(mr_iid) + + # Get all diffs + changes = mr.changes() + diffs = [] + # changes() returns dict, but typing is Response | dict + changes_dict = changes if isinstance(changes, dict) else {} + + for change in changes_dict.get("changes", []): + diff = change.get("diff", "") + if diff: + diffs.append(f"diff --git a/{change['old_path']} b/{change['new_path']}\n{diff}") + + return "\n".join(diffs) + + def get_mr_head_sha(self, mr_url: str) -> str: + """ + Get the head SHA of a Merge Request. + + Args: + mr_url: URL of the MR. + + Returns: + The head commit SHA. + """ + mr_iid = int(mr_url.rstrip("/").split("/")[-1]) + mr = self.project.mergerequests.get(mr_iid) + return mr.sha diff --git a/src/seer/automation/codebase/models.py b/src/seer/automation/codebase/models.py index 8696a447..3a06ada0 100644 --- a/src/seer/automation/codebase/models.py +++ b/src/seer/automation/codebase/models.py @@ -63,6 +63,17 @@ class GithubPrReviewComment(TypedDict): in_reply_to: NotRequired[str] +class GitLabMrReviewComment(TypedDict): + """TypedDict for GitLab Merge Request review comments (discussion notes).""" + + body: str + position: NotRequired[dict] # Position for inline comments (new_path, new_line, etc.) + base_sha: NotRequired[str] + start_sha: NotRequired[str] + head_sha: NotRequired[str] + position_type: NotRequired[Literal["text", "image"]] + + # Copied from https://github.com/codecov/bug-prediction-research/blob/main/src/core/typings.py class Location(BaseModel): filename: str diff --git a/src/seer/automation/codebase/repo_client.py b/src/seer/automation/codebase/repo_client.py index e0a47bbf..ddd6834a 100644 --- a/src/seer/automation/codebase/repo_client.py +++ b/src/seer/automation/codebase/repo_client.py @@ -8,7 +8,6 @@ from collections import namedtuple from concurrent.futures import ThreadPoolExecutor from datetime import timedelta -from enum import Enum from typing import Any, Dict, List, Literal import requests @@ -23,13 +22,17 @@ UnknownObjectException, ) from github.GithubRetry import GithubRetry -from github.GitRef import GitRef from github.GitTree import GitTree from github.GitTreeElement import GitTreeElement -from github.PullRequest import PullRequest from github.Repository import Repository from seer.automation.autofix.utils import generate_random_string, sanitize_branch_name +from seer.automation.codebase.base_repo_client import ( + BaseRepoClient, + BranchRefResult, + PullRequestResult, + RepoClientType, +) from seer.automation.codebase.models import GithubPrReviewComment from seer.automation.codebase.utils import get_all_supported_extensions from seer.automation.models import FileChange, FilePatch, InitializationError, RepoDefinition @@ -37,6 +40,18 @@ from seer.configuration import AppConfig from seer.dependency_injection import inject, injected +# Re-export for backward compatibility +__all__ = [ + "RepoClientType", + "BranchRefResult", + "PullRequestResult", + "BaseRepoClient", + "GitHubRepoClient", + "RepoClient", + "get_repo_client", + "autocorrect_repo_name", +] + logger = logging.getLogger(__name__) @@ -129,14 +144,6 @@ def get_codecov_pr_review_app_credentials( return app_id, private_key -class RepoClientType(str, Enum): - READ = "read" - WRITE = "write" - CODECOV_UNIT_TEST = "codecov_unit_test" - CODECOV_PR_REVIEW = "codecov_pr_review" - CODECOV_PR_CLOSED = "codecov_pr_closed" - - class GitTreeElementWithPath: """ A minimal wrapper around GitTreeElement that provides a custom path @@ -192,8 +199,12 @@ def add_items(self, items: List[GitTreeElementWithPath]) -> None: self.tree.extend(items) -class RepoClient: - # TODO: Support other git providers later +class GitHubRepoClient: + """ + GitHub-specific implementation of the repository client. + Provides access to GitHub repositories via the GitHub API. + """ + github_auth: Auth.Token | Auth.AppInstallationAuth github: Github repo: Repository @@ -206,17 +217,15 @@ class RepoClient: base_branch: str repo_definition: RepoDefinition - supported_providers = ["github"] + supported_providers = ["github", "gitlab"] # All supported providers for routing @sentry_sdk.trace def __init__( self, app_id: int | str | None, private_key: str | None, repo_definition: RepoDefinition ): - if repo_definition.provider not in self.supported_providers: - # This should never get here, the repo provider should be checked on the Sentry side but this will make debugging - # easier if it does + if repo_definition.provider != "github": raise InitializationError( - f"Unsupported repo provider: {repo_definition.provider}, only {', '.join(self.supported_providers)} are supported." + f"GitHubRepoClient only supports 'github' provider, got: {repo_definition.provider}" ) GithubRetry.DEFAULT_BACKOFF_MAX = 15 # On retries, new instances are created @@ -282,7 +291,7 @@ def __init__( self.get_git_tree = functools.lru_cache(maxsize=8)(self._get_git_tree) @staticmethod - def check_repo_write_access(repo: RepoDefinition): + def check_repo_write_access(repo: RepoDefinition) -> bool | None: app_id, pk = get_write_app_credentials() if app_id is None or pk is None: @@ -300,7 +309,7 @@ def check_repo_write_access(repo: RepoDefinition): return False @staticmethod - def check_repo_read_access(repo: RepoDefinition): + def check_repo_read_access(repo: RepoDefinition) -> bool | None: app_id, pk = get_read_app_credentials() if app_id is None or pk is None: @@ -821,9 +830,14 @@ def process_one_file_for_git_commit( path=path, mode="100644", type="blob", sha=blob.sha if blob else None ) - def get_branch_ref(self, branch_name: str) -> GitRef | None: + def get_branch_ref(self, branch_name: str) -> BranchRefResult | None: try: - return self.repo.get_git_ref(f"heads/{branch_name}") + git_ref = self.repo.get_git_ref(f"heads/{branch_name}") + return BranchRefResult( + ref=git_ref.ref, + sha=git_ref.object.sha, + name=branch_name, + ) except GithubException as e: if e.status == 404: return None @@ -837,7 +851,7 @@ def create_branch_from_changes( file_changes: list[FileChange] | None = None, branch_name: str | None = None, from_base_sha: bool = False, - ) -> GitRef | None: + ) -> BranchRefResult | None: if not file_patches and not file_changes: raise ValueError("Either file_patches or file_changes must be provided") @@ -902,15 +916,20 @@ def create_branch_from_changes( ) return None - return branch_ref + # Wrap in BranchRefResult for consistent return type across providers + return BranchRefResult( + ref=branch_ref.ref, + sha=branch_ref.object.sha, + name=new_branch_name, + ) def create_pr_from_branch( self, - branch: GitRef, + branch: BranchRefResult, title: str, description: str, provided_base: str | None = None, - ) -> PullRequest: + ) -> PullRequestResult: pulls = self.repo.get_pulls(state="open", head=f"{self.repo_owner}:{branch.ref}") if pulls.totalCount > 0: @@ -924,10 +943,17 @@ def create_pr_from_branch( }, ) - return pulls[0] + existing_pr = pulls[0] + return PullRequestResult( + number=existing_pr.number, + html_url=existing_pr.html_url, + id=existing_pr.id, + head_ref=branch.name, + head_sha=branch.sha, + ) try: - return self.repo.create_pull( + pr = self.repo.create_pull( title=title, body=description, base=provided_base or self.base_branch or self.get_default_branch(), @@ -937,7 +963,7 @@ def create_pr_from_branch( except GithubException as e: if e.status == 422 and "Draft pull requests are not supported" in str(e): # fallback to creating a regular PR if draft PR is not supported - return self.repo.create_pull( + pr = self.repo.create_pull( title=title, body=description, base=provided_base or self.base_branch or self.get_default_branch(), @@ -948,6 +974,14 @@ def create_pr_from_branch( logger.exception("Error creating PR") raise e + return PullRequestResult( + number=pr.number, + html_url=pr.html_url, + id=pr.id, + head_ref=branch.name, + head_sha=branch.sha, + ) + def get_pr_diff_content(self, pr_url: str) -> str: data = requests.get(pr_url, headers=self._get_auth_headers(accept_type="diff")) @@ -1157,15 +1191,21 @@ def get_scaled_time_limit( ).total_seconds() +# Backward-compatible alias +RepoClient = GitHubRepoClient + + def get_repo_client( repos: list[RepoDefinition], repo_name: str | None = None, repo_external_id: str | None = None, type: RepoClientType = RepoClientType.READ, -) -> RepoClient: +) -> BaseRepoClient: """ Gets a repo client for the current single repo or for a given repo name. If there are more than 1 repos, a repo name must be provided. + + Routes to the appropriate provider implementation based on the repo's provider. """ repo: RepoDefinition | None = None if len(repos) == 1: @@ -1180,7 +1220,13 @@ def get_repo_client( "Repo not found. Please provide a valid repo name or external ID." ) - return RepoClient.from_repo_definition(repo, type) + # Route to appropriate provider + if repo.provider == "gitlab": + from seer.automation.codebase.gitlab_repo_client import GitLabRepoClient + + return GitLabRepoClient.from_repo_definition(repo, type) + + return GitHubRepoClient.from_repo_definition(repo, type) def autocorrect_repo_name(readable_repos: list[RepoDefinition], repo_name: str) -> str | None: @@ -1195,7 +1241,9 @@ def autocorrect_repo_name(readable_repos: list[RepoDefinition], repo_name: str) The corrected repository name if a match is found, or None if no match is found """ repo_names = [ - repo.full_name for repo in readable_repos if repo.provider in RepoClient.supported_providers + repo.full_name + for repo in readable_repos + if repo.provider in GitHubRepoClient.supported_providers ] if repo_name and repo_name in repo_names: return repo_name diff --git a/src/seer/automation/codegen/bug_prediction_component.py b/src/seer/automation/codegen/bug_prediction_component.py index 44ed72c7..964c6fd9 100644 --- a/src/seer/automation/codegen/bug_prediction_component.py +++ b/src/seer/automation/codegen/bug_prediction_component.py @@ -3,7 +3,7 @@ from functools import partial from typing import Literal, TypeAlias -from langfuse.decorators import langfuse_context, observe +from langfuse import observe from seer.automation.agent.agent import AgentConfig, LlmAgent, RunConfig from seer.automation.agent.client import AnthropicProvider, GeminiProvider, LlmClient @@ -25,6 +25,7 @@ from seer.automation.codegen.prompts import BugPredictionPrompts from seer.automation.component import BaseComponent from seer.dependency_injection import copy_modules_initializer, inject, injected +from seer.langfuse import langfuse_context class FilterFilesComponent(BaseComponent[FilterFilesRequest, FilterFilesOutput]): @@ -71,8 +72,8 @@ def invoke( response_format=list[FilenameFromThisPR], ) - if response.parsed is None: - self.logger.warning( + if response.parsed is None: # type: ignore[unreachable] + self.logger.warning( # type: ignore[unreachable] "Failed to filter files intelligently.", ) pr_files_picked = pr_files_filterable @@ -243,8 +244,8 @@ def invoke( max_tokens=8192, ) - if response.parsed is None: - self.logger.warning("Failed to extract structured information from bug prediction") + if response.parsed is None: # type: ignore[unreachable] + self.logger.warning("Failed to extract structured information from bug prediction") # type: ignore[unreachable] return FormatterOutput(bug_predictions=[]) return FormatterOutput(bug_predictions=response.parsed) diff --git a/src/seer/automation/codegen/bug_prediction_step.py b/src/seer/automation/codegen/bug_prediction_step.py index 65abe66c..0e605872 100644 --- a/src/seer/automation/codegen/bug_prediction_step.py +++ b/src/seer/automation/codegen/bug_prediction_step.py @@ -2,7 +2,7 @@ from typing import Any import requests -from langfuse.decorators import observe +from langfuse import observe from celery_app.app import celery_app from integrations.codecov.codecov_auth import get_codecov_auth_header diff --git a/src/seer/automation/codegen/evals/datasets.py b/src/seer/automation/codegen/evals/datasets.py index f0554820..07d5972a 100644 --- a/src/seer/automation/codegen/evals/datasets.py +++ b/src/seer/automation/codegen/evals/datasets.py @@ -26,6 +26,7 @@ from seer.automation.codegen.evals.models import EvalItemInput, EvalItemOutput from seer.automation.codegen.models import BugPrediction +from seer.langfuse import fetch_trace, get_dataset_item @click.group() @@ -46,7 +47,7 @@ def run_summary(dataset_name: str, run_name: str): """ langfuse = Langfuse() try: - run = langfuse.get_dataset_run(dataset_name, run_name) + run = langfuse.get_dataset_run(dataset_name=dataset_name, run_name=run_name) except NotFoundError as e: click.echo(f"❌ Run {run_name} not found: {e}") return @@ -177,7 +178,7 @@ def run_details(dataset_name: str, run_name: str, format: Literal["md"]): """ langfuse = Langfuse() try: - run = langfuse.get_dataset_run(dataset_name, run_name) + run = langfuse.get_dataset_run(dataset_name=dataset_name, run_name=run_name) except NotFoundError as e: click.echo(f"❌ Run {run_name} not found: {e}") return @@ -401,8 +402,8 @@ def get_relevant_info_for_item(langfuse: Langfuse, item: DatasetRunItem) -> Rele Returns: RelevantItemInfo: Structured information about the item """ - trace = langfuse.fetch_trace(item.trace_id) - dataset_item = langfuse.get_dataset_item(item.dataset_item_id) + trace = fetch_trace(langfuse, item.trace_id) + dataset_item = get_dataset_item(langfuse, item.dataset_item_id) # Load the expected issues from the dataset item. if not dataset_item.expected_output: diff --git a/src/seer/automation/codegen/evals/evaluations.py b/src/seer/automation/codegen/evals/evaluations.py index d363d72e..908ab2cf 100644 --- a/src/seer/automation/codegen/evals/evaluations.py +++ b/src/seer/automation/codegen/evals/evaluations.py @@ -1,7 +1,9 @@ import logging -from langfuse.client import DatasetItemClient -from langfuse.decorators import observe +from langfuse import observe + +# DatasetItemClient moved to private module in langfuse 3.x +from langfuse._client.client import DatasetItemClient # type: ignore[attr-defined] from seer.automation.agent.client import GeminiProvider, LlmClient from seer.automation.codegen.bug_prediction_step import BugPredictionStep @@ -29,10 +31,10 @@ def sync_run_evaluation_on_item( - Fetching the Sentry issues """ - item = EvalItemInput.model_validate(item.input) + eval_item = EvalItemInput.model_validate(item.input) # Build the request from the item. - request = item.get_request() + request = eval_item.get_request() # Make sure we don't post to Overwatch. request.should_post_to_overwatch = False diff --git a/src/seer/automation/codegen/evals/tasks.py b/src/seer/automation/codegen/evals/tasks.py index 6556fd4a..a49a3a15 100644 --- a/src/seer/automation/codegen/evals/tasks.py +++ b/src/seer/automation/codegen/evals/tasks.py @@ -18,6 +18,7 @@ ) from seer.configuration import AppConfig from seer.dependency_injection import inject, injected +from seer.langfuse import get_dataset_item logger = logging.getLogger(__name__) @@ -92,7 +93,7 @@ def run_relevant_warnings_evaluation_on_item( # This can fail with LangfuseNotFoundError if the item is not found or if it's not active. try: - dataset_item = langfuse.get_dataset_item(item_id) + dataset_item = get_dataset_item(langfuse, item_id) except NotFoundError: logger.error(f"Item {item_id} not found or not active. Skipping scoring.") return @@ -109,18 +110,19 @@ def run_relevant_warnings_evaluation_on_item( scoring_model = "gemini-2.5-pro-preview-03-25" dataset_item_trace_id = None - with dataset_item.observe(run_name=run_name, run_description=run_description) as trace_id: - dataset_item_trace_id = trace_id + # In langfuse 3.x, observe() is replaced by run() which yields a span + with dataset_item.run(run_name=run_name, run_description=run_description) as span: + dataset_item_trace_id = span.trace_id try: - bug_predictions = sync_run_evaluation_on_item(dataset_item, langfuse_session_id=trace_id) # type: ignore - langfuse.score( + bug_predictions = sync_run_evaluation_on_item(dataset_item, langfuse_session_id=span.trace_id) # type: ignore + langfuse.create_score( trace_id=dataset_item_trace_id, name="error_running_evaluation", value=0, ) except Exception as e: logger.exception(f"Error running evaluation: {e}") - langfuse.score( + langfuse.create_score( trace_id=dataset_item_trace_id, name="error_running_evaluation", value=1, @@ -152,31 +154,31 @@ def run_relevant_warnings_evaluation_on_item( bugs_not_found = [i for i in range(len(list_of_issues)) if i not in bugs_matched] scores_content = [score.match_score for score in valid_scores] location_match = [score.location_match for score in valid_scores] - langfuse.score( + langfuse.create_score( comment=f"Expected number of bugs: {len(list_of_issues)}; Actual bugs found: {[ (suggestion_idx, bug_idx) for suggestion_idx, bug_idx in zip(bug_predictions_matched, bugs_matched)]}", trace_id=dataset_item_trace_id, name=make_score_name(model=scoring_model, n_panel=scoring_n_panel, name="bugs_found_count"), value=len(bugs_matched), ) - langfuse.score( + langfuse.create_score( comment=f"Individual bug location matches: {location_match}", trace_id=dataset_item_trace_id, name=make_score_name(model=scoring_model, n_panel=scoring_n_panel, name="location_match"), value=sum(location_match), ) - langfuse.score( + langfuse.create_score( comment=f"Individual bug content matches: {scores_content}", trace_id=dataset_item_trace_id, name=make_score_name(model=scoring_model, n_panel=scoring_n_panel, name="content_match"), value=sum(scores_content), ) - langfuse.score( + langfuse.create_score( comment=f"Bugs not found: {bugs_not_found}", trace_id=dataset_item_trace_id, name=make_score_name(model=scoring_model, n_panel=scoring_n_panel, name="bugs_not_found"), value=len(bugs_not_found), ) - langfuse.score( + langfuse.create_score( comment=f"Suggestions not matched to any bug: {bug_predictions_not_matched}", trace_id=dataset_item_trace_id, name=make_score_name(model=scoring_model, n_panel=scoring_n_panel, name="noise"), diff --git a/src/seer/automation/codegen/pr_closed_step.py b/src/seer/automation/codegen/pr_closed_step.py index e0cfb31d..4f7eb590 100644 --- a/src/seer/automation/codegen/pr_closed_step.py +++ b/src/seer/automation/codegen/pr_closed_step.py @@ -2,7 +2,7 @@ from typing import Any from github.PullRequestComment import PullRequestComment -from langfuse.decorators import observe +from langfuse import observe from sentry_sdk.ai.monitoring import ai_track from sqlalchemy.dialects.postgresql import insert diff --git a/src/seer/automation/codegen/pr_review_coding_component.py b/src/seer/automation/codegen/pr_review_coding_component.py index 8146cf18..a043c0d8 100644 --- a/src/seer/automation/codegen/pr_review_coding_component.py +++ b/src/seer/automation/codegen/pr_review_coding_component.py @@ -1,6 +1,6 @@ import logging -from langfuse.decorators import observe +from langfuse import observe from sentry_sdk.ai.monitoring import ai_track from seer.automation.agent.agent import AgentConfig, LlmAgent, RunConfig @@ -26,7 +26,7 @@ def _get_client_type(self, is_codecov_request: bool) -> RepoClientType: @observe(name="Review PR") @ai_track(description="Review PR") @inject - def invoke( + def invoke( # type: ignore[override] self, request: CodePrReviewRequest, is_codecov_request: bool, diff --git a/src/seer/automation/codegen/pr_review_step.py b/src/seer/automation/codegen/pr_review_step.py index 45c07a16..b2abc73e 100644 --- a/src/seer/automation/codegen/pr_review_step.py +++ b/src/seer/automation/codegen/pr_review_step.py @@ -1,6 +1,6 @@ from typing import Any -from langfuse.decorators import observe +from langfuse import observe from sentry_sdk.ai.monitoring import ai_track from celery_app.app import celery_app diff --git a/src/seer/automation/codegen/relevant_warnings_component.py b/src/seer/automation/codegen/relevant_warnings_component.py index e86ec048..5699bd43 100644 --- a/src/seer/automation/codegen/relevant_warnings_component.py +++ b/src/seer/automation/codegen/relevant_warnings_component.py @@ -7,7 +7,7 @@ import numpy as np from cachetools import LRUCache, cached # type: ignore[import-untyped] from cachetools.keys import hashkey # type: ignore[import-untyped] -from langfuse.decorators import observe +from langfuse import observe from sentry_sdk.ai.monitoring import ai_track from seer.automation.agent.client import GeminiProvider, LlmClient @@ -512,8 +512,8 @@ def invoke( max_tokens=2048, timeout=15.0, ) - if completion.parsed is None: # Gemini quirk - self.logger.warning( + if completion.parsed is None: # Gemini quirk # type: ignore[unreachable] + self.logger.warning( # type: ignore[unreachable] f"No response from LLM for warning {warning_and_pr_file.warning.id} and issue {issue.id}" ) continue @@ -593,6 +593,6 @@ def invoke( temperature=0.0, max_tokens=8192, ) - if completion.parsed is None: - return None + if completion.parsed is None: # type: ignore[unreachable] + return None # type: ignore[unreachable] return CodePredictStaticAnalysisSuggestionsOutput(suggestions=completion.parsed.suggestions) diff --git a/src/seer/automation/codegen/relevant_warnings_step.py b/src/seer/automation/codegen/relevant_warnings_step.py index 4f9f2fa0..eb6ac626 100644 --- a/src/seer/automation/codegen/relevant_warnings_step.py +++ b/src/seer/automation/codegen/relevant_warnings_step.py @@ -4,7 +4,7 @@ from typing import Any import requests -from langfuse.decorators import observe +from langfuse import observe from sentry_sdk.ai.monitoring import ai_track from celery_app.app import celery_app @@ -284,7 +284,7 @@ def _invoke(self, **kwargs) -> None: fixable_issues=fixable_issues, pr_files=pr_files, ) - static_analysis_suggestions_output: CodePredictStaticAnalysisSuggestionsOutput = ( + static_analysis_suggestions_output: CodePredictStaticAnalysisSuggestionsOutput | None = ( static_analysis_suggestions_component.invoke(static_analysis_suggestions_request) ) diff --git a/src/seer/automation/codegen/retry_unittest_coding_component.py b/src/seer/automation/codegen/retry_unittest_coding_component.py index 17400ce9..a8ae26e1 100644 --- a/src/seer/automation/codegen/retry_unittest_coding_component.py +++ b/src/seer/automation/codegen/retry_unittest_coding_component.py @@ -1,6 +1,6 @@ import logging -from langfuse.decorators import observe +from langfuse import observe from sentry_sdk.ai.monitoring import ai_track from integrations.codecov.codecov_client import CodecovClient @@ -32,7 +32,7 @@ class RetryUnitTestCodingComponent(BaseComponent[CodeUnitTestRequest, CodeUnitTe @observe(name="Retry unit tests") @ai_track(description="Retry unit test generation") @inject - def invoke( + def invoke( # type: ignore[override] self, request: CodeUnitTestRequest, previous_run_context: DbPrContextToUnitTestGenerationRunIdMapping, diff --git a/src/seer/automation/codegen/retry_unittest_step.py b/src/seer/automation/codegen/retry_unittest_step.py index 9375eee9..46b26000 100644 --- a/src/seer/automation/codegen/retry_unittest_step.py +++ b/src/seer/automation/codegen/retry_unittest_step.py @@ -1,6 +1,6 @@ from typing import Any, Optional -from langfuse.decorators import observe +from langfuse import observe from sentry_sdk.ai.monitoring import ai_track from celery_app.app import celery_app diff --git a/src/seer/automation/codegen/unit_test_coding_component.py b/src/seer/automation/codegen/unit_test_coding_component.py index 8c9ed8fc..20fbfd1f 100644 --- a/src/seer/automation/codegen/unit_test_coding_component.py +++ b/src/seer/automation/codegen/unit_test_coding_component.py @@ -1,6 +1,6 @@ import logging -from langfuse.decorators import observe +from langfuse import observe from sentry_sdk.ai.monitoring import ai_track from integrations.codecov.codecov_client import CodecovClient @@ -39,7 +39,7 @@ def _get_client_type(self, is_codecov_request: bool) -> RepoClientType: @observe(name="Generate unit tests") @ai_track(description="Generate unit tests") @inject - def invoke( + def invoke( # type: ignore[override] self, request: CodeUnitTestRequest, is_codecov_request: bool, diff --git a/src/seer/automation/codegen/unittest_step.py b/src/seer/automation/codegen/unittest_step.py index afb5ff88..4c230244 100644 --- a/src/seer/automation/codegen/unittest_step.py +++ b/src/seer/automation/codegen/unittest_step.py @@ -1,6 +1,6 @@ from typing import Any -from langfuse.decorators import observe +from langfuse import observe from sentry_sdk.ai.monitoring import ai_track from celery_app.app import celery_app diff --git a/src/seer/automation/explorer/__init__.py b/src/seer/automation/explorer/__init__.py new file mode 100644 index 00000000..c9ab433d --- /dev/null +++ b/src/seer/automation/explorer/__init__.py @@ -0,0 +1,11 @@ +# Seer Explorer - LLM-powered chat for issue analysis +# +# This module provides full Explorer functionality with Claude integration. +# Requires ANTHROPIC_API_KEY environment variable to be set. +# +# Components: +# - models.py: Request/response models +# - state.py: State management using DbRunState +# - agent.py: ExplorerAgent class for LLM integration +# - tasks.py: Celery tasks for async processing +# - prompts.py: System prompts for Claude diff --git a/src/seer/automation/explorer/agent.py b/src/seer/automation/explorer/agent.py new file mode 100644 index 00000000..1455d87a --- /dev/null +++ b/src/seer/automation/explorer/agent.py @@ -0,0 +1,321 @@ +""" +Explorer Agent for LLM-powered chat functionality. + +This agent processes user queries about issues and provides AI-powered responses +using Anthropic Claude. +""" + +from __future__ import annotations + +import json +import logging +import os +from typing import Any + +import anthropic +import sentry_sdk + +from seer.automation.explorer.models import ( + Artifact, + ExplorerStatus, + Message, + ToolCall, + ToolDefinition, +) +from seer.automation.explorer.prompts import get_explorer_system_prompt +from seer.automation.explorer.state import ExplorerRunState +from seer.configuration import AppConfig + +logger = logging.getLogger(__name__) + + +class ExplorerAgent: + """ + Agent that processes Explorer chat messages using Claude. + + Handles: + - Loading conversation history from state + - Building system prompts with user context + - Calling Claude API + - Parsing artifacts from responses + - Updating state with results + """ + + def __init__( + self, + state: ExplorerRunState, + organization_id: int | None = None, + ): + self.state = state + self.organization_id = organization_id + self._client: anthropic.Anthropic | None = None + + @property + def client(self) -> anthropic.Anthropic: + """Get or create the Anthropic client.""" + if self._client is None: + # Try config first, then environment variable + api_key = os.environ.get("ANTHROPIC_API_KEY") + try: + from seer.dependency_injection import resolve + + app_config = resolve(AppConfig) + api_key = app_config.ANTHROPIC_API_KEY or api_key + except Exception: + pass # Use environment variable if injection fails + + if not api_key: + raise ValueError( + "ANTHROPIC_API_KEY is required for Explorer functionality. " + "Set it in configuration or as an environment variable." + ) + self._client = anthropic.Anthropic(api_key=api_key) + return self._client + + def _get_model(self) -> str: + """Get the model name from configuration.""" + model = os.environ.get("EXPLORER_MODEL") + if model: + return model + + try: + from seer.dependency_injection import resolve + + app_config = resolve(AppConfig) + if app_config.EXPLORER_MODEL: + return app_config.EXPLORER_MODEL + except Exception: + pass # Use default if injection fails + + return "claude-sonnet-4-20250514" + + def process_message( + self, + query: str, + artifact_key: str | None = None, + artifact_schema: dict[str, Any] | None = None, + tools: list[ToolDefinition] | None = None, + metadata: dict[str, Any] | None = None, + ) -> int: + """ + Process a user message and generate a response. + + Args: + query: The user's query/message + artifact_key: Optional key for extracting a specific artifact + artifact_schema: Optional JSON schema for the artifact + tools: Optional list of custom tool definitions + metadata: Optional metadata about the request + + Returns: + The run_id + """ + try: + # Add user message to history (not loading - user message is complete) + user_message = Message(role="user", content=query) + self.state.add_message(user_message) + + # Build messages from history + messages = self._build_messages() + + # Build system prompt + system_prompt = get_explorer_system_prompt( + artifact_key=artifact_key, + artifact_schema=artifact_schema, + ) + + # Add context from metadata if available + if metadata: + context_parts = [] + if "issue" in metadata: + context_parts.append( + f"## Issue Context\n{json.dumps(metadata['issue'], indent=2)}" + ) + if "stacktrace" in metadata: + context_parts.append(f"## Stack Trace\n{metadata['stacktrace']}") + if context_parts: + system_prompt += "\n\n" + "\n\n".join(context_parts) + + # Call Claude + response = self._call_claude( + messages=messages, + system_prompt=system_prompt, + tools=tools, + ) + + # Parse artifacts from response if requested + artifacts = [] + if artifact_key and artifact_schema: + extracted = self._extract_artifact( + response.content or "", + artifact_key, + artifact_schema, + ) + if extracted: + artifacts.append(extracted) + + # Add assistant response to history + assistant_message = Message( + role="assistant", + content=response.content, + tool_calls=response.tool_calls, + ) + self.state.add_message(assistant_message, artifacts=artifacts) + + # Update status + self.state.set_status(ExplorerStatus.COMPLETED) + self.state.set_loading(False) + + except Exception as e: + logger.exception(f"Error processing Explorer message: {e}") + sentry_sdk.capture_exception(e) + + # Add error message + error_message = Message( + role="assistant", + content=f"I encountered an error while processing your request: {str(e)}", + ) + self.state.add_message(error_message) + self.state.set_status(ExplorerStatus.ERROR) + self.state.set_loading(False) + + return self.state.run_id + + def _build_messages(self) -> list[dict[str, Any]]: + """Build Claude messages from conversation history.""" + messages = [] + memory_blocks = self.state.get_memory() + + for block in memory_blocks: + msg = block.message + if msg.role == "user": + messages.append({"role": "user", "content": msg.content or ""}) + elif msg.role == "assistant": + if msg.tool_calls: + # Handle tool use messages + content = [] + if msg.content: + content.append({"type": "text", "text": msg.content}) + for tool_call in msg.tool_calls: + content.append( + { + "type": "tool_use", + "id": tool_call.id or "", + "name": tool_call.function, + "input": json.loads(tool_call.args) if tool_call.args else {}, + } + ) + messages.append({"role": "assistant", "content": content}) + else: + messages.append({"role": "assistant", "content": msg.content or ""}) + elif msg.role == "tool": + messages.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": msg.tool_call_id or "", + "content": msg.content or "", + } + ], + } + ) + + return messages + + def _call_claude( + self, + messages: list[dict[str, Any]], + system_prompt: str, + tools: list[ToolDefinition] | None = None, + ) -> Message: + """Call Claude API and return the response as a Message.""" + # Build tools if provided + claude_tools = None + if tools: + claude_tools = [ + { + "name": tool.name, + "description": tool.description, + "input_schema": tool.param_schema or {"type": "object", "properties": {}}, + } + for tool in tools + ] + + # Get model from configuration + model = self._get_model() + + try: + response = self.client.messages.create( + model=model, + max_tokens=4096, + system=system_prompt, + messages=messages, + tools=claude_tools if claude_tools else anthropic.NOT_GIVEN, + ) + + # Convert response to Message + message = Message(role="assistant") + + for block in response.content: + if block.type == "text": + message.content = block.text + elif block.type == "tool_use": + if not message.tool_calls: + message.tool_calls = [] + message.tool_calls.append( + ToolCall( + id=block.id, + function=block.name, + args=json.dumps(block.input), + ) + ) + + return message + + except anthropic.APIError as e: + logger.error(f"Anthropic API error: {e}") + raise + + def _extract_artifact( + self, + response_text: str, + artifact_key: str, + artifact_schema: dict[str, Any], + ) -> Artifact | None: + """ + Try to extract an artifact from the response text. + + This is a simple extraction that looks for JSON blocks in the response. + More sophisticated extraction could be added based on the schema. + """ + try: + # Look for JSON code blocks + import re + + json_pattern = r"```(?:json)?\s*\n?(.*?)\n?```" + matches = re.findall(json_pattern, response_text, re.DOTALL) + + for match in matches: + try: + data = json.loads(match.strip()) + # Basic schema validation - check if required fields are present + if "properties" in artifact_schema: + required = artifact_schema.get("required", []) + if all(key in data for key in required): + return Artifact( + key=artifact_key, + data=data, + reason="Extracted from response", + ) + except json.JSONDecodeError: + continue + + # If no JSON block found, try to extract structured data from the response + # This is a fallback for simpler cases + return None + + except Exception as e: + logger.warning(f"Failed to extract artifact: {e}") + return None diff --git a/src/seer/automation/explorer/models.py b/src/seer/automation/explorer/models.py new file mode 100644 index 00000000..c2a7848a --- /dev/null +++ b/src/seer/automation/explorer/models.py @@ -0,0 +1,313 @@ +""" +Models for Seer Explorer endpoints. + +These models support the full Explorer chat functionality with LLM-powered responses. +""" + +from __future__ import annotations + +import datetime +import enum +import uuid +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class ExplorerStatus(str, enum.Enum): + """Status of an Explorer run.""" + + PROCESSING = "processing" + COMPLETED = "completed" + ERROR = "error" + AWAITING_USER_INPUT = "awaiting_user_input" + + @classmethod + def terminal(cls) -> frozenset["ExplorerStatus"]: + return frozenset((cls.COMPLETED, cls.ERROR)) + + +class ToolCall(BaseModel): + """A tool call made by the assistant.""" + + id: str | None = None + function: str + args: str + + +class Message(BaseModel): + """A message in the conversation.""" + + role: Literal["user", "assistant", "tool_use", "tool"] + content: str | None = None + tool_calls: list[ToolCall] | None = None + tool_call_id: str | None = None + + +class Artifact(BaseModel): + """An artifact extracted from the conversation.""" + + key: str + data: dict[str, Any] | None = None + reason: str = "" + + +class FilePatch(BaseModel): + """A file patch representing changes.""" + + path: str + diff: str | None = None + added: int = 0 + removed: int = 0 + + +class MemoryBlock(BaseModel): + """A block of memory in the Explorer conversation.""" + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + message: Message + timestamp: str = Field(default_factory=lambda: datetime.datetime.now().isoformat()) + loading: bool = False + artifacts: list[Artifact] = Field(default_factory=list) + file_patches: list[FilePatch] | None = None + + +class PendingUserInput(BaseModel): + """Details about pending user input.""" + + prompt: str + options: list[str] | None = None + + +class CodingAgentState(BaseModel): + """State of a coding agent.""" + + status: str = "idle" + message: str | None = None + + +class SeerRunState(BaseModel): + """The full state of a Seer Explorer run.""" + + run_id: int + blocks: list[MemoryBlock] = Field(default_factory=list) + status: ExplorerStatus = ExplorerStatus.PROCESSING + updated_at: str = Field(default_factory=lambda: datetime.datetime.now().isoformat()) + pending_user_input: PendingUserInput | None = None + repo_pr_states: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, Any] | None = None + coding_agents: dict[str, CodingAgentState] = Field(default_factory=dict) + + +# ============================================================================= +# Request/Response Models +# ============================================================================= + + +class ToolDefinition(BaseModel): + """A custom tool definition passed from Sentry.""" + + name: str + module_path: str | None = None + description: str = "" + param_schema: dict[str, Any] | None = None + + +class ConduitParams(BaseModel): + """Parameters for Conduit streaming support.""" + + channel_id: str | None = None + url: str | None = None + + +class ExplorerRunsRequest(BaseModel): + """Request to list explorer runs.""" + + organization_id: int | None = None + category_key: str | None = None + category_value: str | None = None + + +class ExplorerRunInfo(BaseModel): + """Summary info about an explorer run.""" + + run_id: int + title: str = "Autofix Run" # Required by Sentry + created_at: str + last_triggered_at: str # Required by Sentry (replaces updated_at) + status: ExplorerStatus + category_key: str | None = None + category_value: str | None = None + + +class ExplorerRunsResponse(BaseModel): + """Response for listing explorer runs.""" + + data: list[ExplorerRunInfo] = Field(default_factory=list) # Sentry expects "data" key + + +class ExplorerChatRequest(BaseModel): + """Request for explorer chat.""" + + run_id: int | None = None + query: str | None = None + message: str | None = None # Alias for query + organization_id: int | None = None + category_key: str | None = None + category_value: str | None = None + artifact_key: str | None = None + artifact_schema: dict[str, Any] | None = None + tools: list[ToolDefinition] | None = None + conduit: ConduitParams | None = None + metadata: dict[str, Any] | None = None + + class Config: + extra = "allow" + + def get_query(self) -> str | None: + """Get the query from either query or message field.""" + return self.query or self.message + + +class ExplorerChatResponse(BaseModel): + """Response for explorer chat.""" + + status: Literal["processing", "completed", "error", "not_available"] = "processing" + run_id: int | None = None + message: str | None = None + + +class ExplorerStateRequest(BaseModel): + """Request for explorer run state.""" + + run_id: int + + class Config: + extra = "allow" + + +class ExplorerStateResponse(BaseModel): + """Response for explorer run state.""" + + session: SeerRunState | None = None + status: Literal["ok", "not_found", "error"] = "ok" + message: str | None = None + + +class ExplorerUpdateRequest(BaseModel): + """Request to update explorer run.""" + + run_id: int + update_type: str | None = None + payload: dict[str, Any] | None = None + + class Config: + extra = "allow" + + +class ExplorerUpdateResponse(BaseModel): + """Response for explorer update.""" + + status: Literal["ok", "error", "not_available"] = "ok" + message: str | None = None + + +# ============================================================================= +# Additional stub models for missing endpoints (kept for compatibility) +# ============================================================================= + + +class CodingAgentStateSetRequest(BaseModel): + """Request to set coding agent state.""" + + run_id: int | None = None + + class Config: + extra = "allow" + + +class CodingAgentStateSetResponse(BaseModel): + """Response for setting coding agent state.""" + + status: Literal["ok", "not_available"] = "not_available" + message: str = "Coding agent not available in self-hosted mode" + + +class CodingAgentStateUpdateRequest(BaseModel): + """Request to update coding agent state.""" + + run_id: int | None = None + + class Config: + extra = "allow" + + +class CodingAgentStateUpdateResponse(BaseModel): + """Response for updating coding agent state.""" + + status: Literal["ok", "not_available"] = "not_available" + message: str = "Coding agent not available in self-hosted mode" + + +class AutofixPromptRequest(BaseModel): + """Request for autofix prompt.""" + + run_id: int | None = None + + class Config: + extra = "allow" + + +class AutofixPromptResponse(BaseModel): + """Response for autofix prompt.""" + + prompt: str | None = None + message: str = "Autofix prompt not available in self-hosted mode" + + +class CodegenPrReviewRerunRequest(BaseModel): + """Request to rerun PR review.""" + + run_id: int | None = None + + class Config: + extra = "allow" + + +class CodegenPrReviewRerunResponse(BaseModel): + """Response for PR review rerun.""" + + status: Literal["ok", "not_available"] = "not_available" + message: str = "PR review rerun not available in self-hosted mode" + + +class ProjectPreferenceBulkRequest(BaseModel): + """Request for bulk project preferences.""" + + project_ids: list[int] = Field(default_factory=list) + + class Config: + extra = "allow" + + +class ProjectPreferenceBulkResponse(BaseModel): + """Response for bulk project preferences.""" + + preferences: dict[str, Any] = Field(default_factory=dict) + message: str = "Bulk preferences retrieved" + + +class ProjectPreferenceBulkSetRequest(BaseModel): + """Request to bulk set project preferences.""" + + preferences: dict[str, Any] = Field(default_factory=dict) + + class Config: + extra = "allow" + + +class ProjectPreferenceBulkSetResponse(BaseModel): + """Response for bulk setting project preferences.""" + + status: Literal["ok", "not_available"] = "ok" + message: str = "Bulk preferences set" diff --git a/src/seer/automation/explorer/prompts.py b/src/seer/automation/explorer/prompts.py new file mode 100644 index 00000000..95baf772 --- /dev/null +++ b/src/seer/automation/explorer/prompts.py @@ -0,0 +1,66 @@ +""" +System prompts for Explorer mode. +""" + +EXPLORER_SYSTEM_PROMPT = """You are Seer, an AI assistant that helps developers understand and fix issues in their code. You are integrated with Sentry, an error monitoring platform. + +## Your Capabilities + +You have access to: +- Issue details including error messages, stack traces, and exception information +- User context about their codebase and development environment +- The ability to analyze errors and suggest solutions + +## Your Goals + +When helping users: +1. **Understand the Error**: Carefully analyze the error message, stack trace, and any contextual information provided. +2. **Identify Root Causes**: Look for patterns in the error that indicate the underlying problem. +3. **Explain Clearly**: Describe what went wrong in plain language that developers can understand. +4. **Suggest Solutions**: Provide actionable steps to fix the issue, including code examples where appropriate. +5. **Be Concise**: Keep responses focused and avoid unnecessary verbosity. + +## Response Guidelines + +- Start by acknowledging what issue you're looking at +- Explain the likely cause of the error +- Provide specific, actionable recommendations +- Include code snippets when they would be helpful +- If you're uncertain about something, say so rather than guessing + +## Artifact Instructions + +{artifact_instructions} + +## Important Notes + +- Be direct and helpful +- Focus on practical solutions +- If the error context is insufficient, ask clarifying questions +- Consider common patterns and best practices when suggesting fixes +""" + +ARTIFACT_INSTRUCTIONS_DEFAULT = """When you identify important information that should be captured as a structured artifact, format it clearly in your response. The system will parse your response for any artifacts that match the requested schema.""" + +ARTIFACT_INSTRUCTIONS_WITH_SCHEMA = """You've been asked to extract a specific artifact with the following details: + +**Artifact Key**: {artifact_key} +**Schema**: {artifact_schema} + +When you identify information that matches this schema, include it in your response. Format the data clearly so it can be extracted. If you cannot find information matching the requested schema, explain why in your response.""" + + +def get_explorer_system_prompt( + artifact_key: str | None = None, + artifact_schema: dict | None = None, +) -> str: + """Build the full system prompt with artifact instructions.""" + if artifact_key and artifact_schema: + artifact_instructions = ARTIFACT_INSTRUCTIONS_WITH_SCHEMA.format( + artifact_key=artifact_key, + artifact_schema=artifact_schema, + ) + else: + artifact_instructions = ARTIFACT_INSTRUCTIONS_DEFAULT + + return EXPLORER_SYSTEM_PROMPT.format(artifact_instructions=artifact_instructions) diff --git a/src/seer/automation/explorer/state.py b/src/seer/automation/explorer/state.py new file mode 100644 index 00000000..2a63c73b --- /dev/null +++ b/src/seer/automation/explorer/state.py @@ -0,0 +1,292 @@ +""" +State management for Explorer runs. + +Uses DbRunState with type="explorer" to store run metadata and state. +Conversation history is stored in DbRunMemory. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import datetime +import logging +from enum import Enum +from typing import Any + +from sqlalchemy import select + +from seer.automation.explorer.models import ( + Artifact, + ExplorerRunInfo, + ExplorerStatus, + MemoryBlock, + Message, + SeerRunState, +) +from seer.db import DbRunMemory, DbRunState, Session + +logger = logging.getLogger(__name__) + + +class DbStateRunTypes(str, Enum): + """Run types stored in DbRunState.""" + + EXPLORER = "explorer" + + +@dataclasses.dataclass +class ExplorerRunState: + """ + State wrapper for Explorer runs. + + Provides methods to create, retrieve, and update Explorer run state + stored in DbRunState and DbRunMemory. + """ + + run_id: int + type: DbStateRunTypes = DbStateRunTypes.EXPLORER + + @classmethod + def create( + cls, + *, + organization_id: int | None = None, + category_key: str | None = None, + category_value: str | None = None, + group_id: int | None = None, + metadata: dict[str, Any] | None = None, + ) -> "ExplorerRunState": + """Create a new Explorer run.""" + now = datetime.datetime.now(datetime.UTC) + + initial_state = SeerRunState( + run_id=-1, # Will be set after insert + blocks=[], + status=ExplorerStatus.PROCESSING, + updated_at=now.isoformat(), + metadata=metadata or {}, + ) + + # Store category info in metadata + state_metadata = metadata or {} + if organization_id is not None: + state_metadata["organization_id"] = organization_id + if category_key is not None: + state_metadata["category_key"] = category_key + if category_value is not None: + state_metadata["category_value"] = category_value + + initial_state.metadata = state_metadata + + # Derive group_id from category_value if not explicitly provided + # When category_key is "autofix", category_value is the issue/group ID + effective_group_id = group_id + if effective_group_id is None and category_key == "autofix" and category_value: + try: + effective_group_id = int(category_value) + except (ValueError, TypeError): + pass + + with Session() as session: + db_state = DbRunState( + value=initial_state.model_dump(mode="json"), + type=cls.type.value, + group_id=effective_group_id, + ) + session.add(db_state) + session.flush() + + # Update run_id in state + initial_state.run_id = db_state.id + db_state.value = initial_state.model_dump(mode="json") + session.merge(db_state) + + # Create empty memory + db_memory = DbRunMemory(run_id=db_state.id, value=[]) + session.add(db_memory) + + session.commit() + + return cls(run_id=db_state.id) + + @classmethod + def get(cls, run_id: int) -> "ExplorerRunState | None": + """Get an existing Explorer run by ID.""" + with Session() as session: + db_state = session.get(DbRunState, run_id) + if db_state is None: + return None + if db_state.type != cls.type.value: + logger.warning(f"Run {run_id} has type {db_state.type}, expected {cls.type.value}") + return None + return cls(run_id=run_id) + + @classmethod + def list( + cls, + *, + organization_id: int | None = None, + category_key: str | None = None, + category_value: str | None = None, + limit: int = 50, + ) -> list[ExplorerRunInfo]: + """List Explorer runs, optionally filtered by organization and category.""" + with Session() as session: + query = select(DbRunState).where(DbRunState.type == cls.type.value) + + # Order by most recent first + query = query.order_by(DbRunState.updated_at.desc()).limit(limit) + + results = session.execute(query).scalars().all() + + runs = [] + for db_state in results: + state = SeerRunState.model_validate(db_state.value) + + # Filter by organization/category if specified + if organization_id is not None: + if state.metadata and state.metadata.get("organization_id") != organization_id: + continue + + if category_key is not None: + if state.metadata and state.metadata.get("category_key") != category_key: + continue + + if category_value is not None: + if state.metadata and state.metadata.get("category_value") != category_value: + continue + + runs.append( + ExplorerRunInfo( + run_id=db_state.id, + title=f"Autofix Run #{db_state.id}", + created_at=db_state.created_at.isoformat(), + last_triggered_at=db_state.updated_at.isoformat(), + status=state.status, + category_key=state.metadata.get("category_key") if state.metadata else None, + category_value=( + state.metadata.get("category_value") if state.metadata else None + ), + ) + ) + + return runs + + def get_state(self) -> SeerRunState: + """Get the current state of the run.""" + with Session() as session: + db_state = session.get(DbRunState, self.run_id) + if db_state is None: + raise ValueError(f"No state found for run {self.run_id}") + return SeerRunState.model_validate(db_state.value) + + def get_memory(self) -> list[MemoryBlock]: + """Get the conversation history.""" + with Session() as session: + db_memory = session.get(DbRunMemory, self.run_id) + if db_memory is None: + return [] + blocks_data = db_memory.value if isinstance(db_memory.value, list) else [] + return [MemoryBlock.model_validate(block) for block in blocks_data] + + @contextlib.contextmanager + def update(self): + """ + Context manager for atomically updating the run state. + + Uses SELECT FOR UPDATE to ensure thread safety. + """ + with Session() as session: + db_state = session.execute( + select(DbRunState).where(DbRunState.id == self.run_id).with_for_update() + ).scalar_one_or_none() + + if db_state is None: + raise ValueError(f"No state found for run {self.run_id}") + + state = SeerRunState.model_validate(db_state.value) + yield state + + # Update timestamps + state.updated_at = datetime.datetime.now(datetime.UTC).isoformat() + db_state.value = state.model_dump(mode="json") + db_state.updated_at = datetime.datetime.now(datetime.UTC) + + session.merge(db_state) + session.commit() + + def add_message(self, message: Message, artifacts: list[Artifact] | None = None) -> MemoryBlock: + """Add a message to the conversation history.""" + block = MemoryBlock( + message=message, + artifacts=artifacts or [], + ) + + with Session() as session: + # Get or create memory + db_memory = session.get(DbRunMemory, self.run_id) + if db_memory is None: + db_memory = DbRunMemory(run_id=self.run_id, value=[]) + session.add(db_memory) + session.flush() + + # Add block to memory - create new list to trigger SQLAlchemy change detection + existing_blocks = list(db_memory.value) if isinstance(db_memory.value, list) else [] + existing_blocks.append(block.model_dump(mode="json")) + db_memory.value = existing_blocks # Assign new list + + session.commit() + + # Also update state blocks + with self.update() as state: + state.blocks.append(block) + + return block + + def set_artifact(self, key: str, data: dict[str, Any] | None, reason: str = "") -> Artifact: + """Set an artifact on the most recent assistant message.""" + artifact = Artifact(key=key, data=data, reason=reason) + + with Session() as session: + db_memory = session.get(DbRunMemory, self.run_id) + if db_memory is None: + raise ValueError(f"No memory found for run {self.run_id}") + + blocks = db_memory.value if isinstance(db_memory.value, list) else [] + + # Find the most recent assistant message and add artifact + for block_data in reversed(blocks): + if block_data.get("message", {}).get("role") == "assistant": + if "artifacts" not in block_data: + block_data["artifacts"] = [] + block_data["artifacts"].append(artifact.model_dump(mode="json")) + break + + db_memory.value = blocks + session.merge(db_memory) + session.commit() + + # Also update state blocks + with self.update() as state: + for block in reversed(state.blocks): + if block.message.role == "assistant": + block.artifacts.append(artifact) + break + + return artifact + + def set_status(self, status: ExplorerStatus): + """Set the run status.""" + with self.update() as state: + state.status = status + + def set_loading(self, loading: bool): + """Set loading state on the most recent block.""" + with self.update() as state: + if state.blocks: + state.blocks[-1].loading = loading + + def to_seer_run_state(self) -> SeerRunState: + """Convert to SeerRunState for API response.""" + return self.get_state() diff --git a/src/seer/automation/explorer/tasks.py b/src/seer/automation/explorer/tasks.py new file mode 100644 index 00000000..03a6a182 --- /dev/null +++ b/src/seer/automation/explorer/tasks.py @@ -0,0 +1,100 @@ +""" +Celery tasks for Explorer functionality. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import sentry_sdk +from celery.exceptions import SoftTimeLimitExceeded + +from celery_app.app import celery_app +from seer.automation.explorer.agent import ExplorerAgent +from seer.automation.explorer.models import ExplorerStatus, ToolDefinition +from seer.automation.explorer.state import ExplorerRunState + +logger = logging.getLogger(__name__) + + +@celery_app.task(time_limit=300, soft_time_limit=280) +def process_explorer_chat( + run_id: int, + query: str, + artifact_key: str | None = None, + artifact_schema: dict[str, Any] | None = None, + tools: list[dict[str, Any]] | None = None, + metadata: dict[str, Any] | None = None, +): + """ + Celery task to process an Explorer chat message. + + This task: + 1. Loads the run state + 2. Creates an ExplorerAgent + 3. Processes the user's message + 4. Updates state with the response + + Args: + run_id: The Explorer run ID + query: The user's query/message + artifact_key: Optional key for extracting a specific artifact + artifact_schema: Optional JSON schema for the artifact + tools: Optional list of custom tool definitions (as dicts) + metadata: Optional metadata about the request + """ + logger.info(f"Processing Explorer chat for run {run_id}") + + try: + # Load state + state = ExplorerRunState.get(run_id) + if state is None: + logger.error(f"Run {run_id} not found") + return + + # Convert tool dicts to ToolDefinition objects + tool_definitions = None + if tools: + tool_definitions = [ToolDefinition.model_validate(t) for t in tools] + + # Get organization_id from state metadata + current_state = state.get_state() + organization_id = ( + current_state.metadata.get("organization_id") if current_state.metadata else None + ) + + # Create agent and process message + agent = ExplorerAgent(state=state, organization_id=organization_id) + agent.process_message( + query=query, + artifact_key=artifact_key, + artifact_schema=artifact_schema, + tools=tool_definitions, + metadata=metadata, + ) + + logger.info(f"Explorer chat completed for run {run_id}") + + except SoftTimeLimitExceeded: + logger.error(f"Explorer chat timed out for run {run_id}") + try: + state = ExplorerRunState.get(run_id) + if state: + state.set_status(ExplorerStatus.ERROR) + state.set_loading(False) + except Exception: + pass + sentry_sdk.capture_message(f"Explorer chat timed out for run {run_id}") + + except Exception as e: + logger.exception(f"Error processing Explorer chat for run {run_id}: {e}") + sentry_sdk.capture_exception(e) + + try: + state = ExplorerRunState.get(run_id) + if state: + state.set_status(ExplorerStatus.ERROR) + state.set_loading(False) + except Exception: + pass diff --git a/src/seer/automation/summarize/issue.py b/src/seer/automation/summarize/issue.py index 570c455c..87a300e9 100644 --- a/src/seer/automation/summarize/issue.py +++ b/src/seer/automation/summarize/issue.py @@ -1,10 +1,10 @@ import textwrap import sentry_sdk -from langfuse.decorators import observe +from langfuse import observe from pydantic import BaseModel -from seer.automation.agent.client import GeminiProvider, LlmClient +from seer.automation.agent.client import LlmClient, OpenAiProvider from seer.automation.autofixability import AutofixabilityModel from seer.automation.models import EventDetails from seer.automation.summarize.models import ( @@ -77,8 +77,9 @@ class IssueSummaryForLlmToGenerate(BaseModel): @sentry_sdk.trace @inject def summarize_issue( - request: SummarizeIssueRequest, llm_client: LlmClient = injected + request: SummarizeIssueRequest, llm_client: LlmClient = injected, **kwargs ) -> IssueSummaryWithScores: + # kwargs accepts langfuse_tags, langfuse_session_id, langfuse_user_id for tracing event_details = EventDetails.from_event( event=request.issue.events[0], issue_title=request.issue.title ) @@ -152,8 +153,9 @@ def _generate_summary(full_context: bool = True) -> IssueSummaryWithScores | Non ) try: + # Use OpenAI for self-hosted (Gemini requires GCP Workload Identity) completion = llm_client.generate_structured( - model=GeminiProvider.model("gemini-2.0-flash-lite-001"), + model=OpenAiProvider.model("gpt-4o-mini"), prompt=prompt, response_format=IssueSummaryForLlmToGenerate, temperature=0.0, @@ -215,7 +217,7 @@ def run_summarize_issue(request: SummarizeIssueRequest) -> SummarizeIssueRespons } ) - summary = summarize_issue(request, **extra_kwargs) + summary = summarize_issue(request, **extra_kwargs) # type: ignore[arg-type] with Session() as session: db_state = summary.to_db_state(request.group_id) diff --git a/src/seer/automation/summarize/replays.py b/src/seer/automation/summarize/replays.py index 63d05695..2cc3e989 100644 --- a/src/seer/automation/summarize/replays.py +++ b/src/seer/automation/summarize/replays.py @@ -1,7 +1,7 @@ import json import textwrap -from langfuse.decorators import observe +from langfuse import observe from pydantic import BaseModel, model_validator from seer.automation.agent.client import LlmClient, OpenAiProvider diff --git a/src/seer/automation/summarize/traces.py b/src/seer/automation/summarize/traces.py index 520abf15..e08e0018 100644 --- a/src/seer/automation/summarize/traces.py +++ b/src/seer/automation/summarize/traces.py @@ -2,7 +2,7 @@ from venv import logger from google.genai.errors import ClientError -from langfuse.decorators import observe +from langfuse import observe from pydantic import BaseModel from seer.automation.agent.client import GeminiProvider, LlmClient diff --git a/src/seer/bootup.py b/src/seer/bootup.py index fee0d0d6..fbe509a5 100644 --- a/src/seer/bootup.py +++ b/src/seer/bootup.py @@ -38,10 +38,11 @@ def bootup( *, start_model_loading: bool, integrations: list[Integration], config: AppConfig = injected ): initialize_sentry_sdk(integrations) - with sentry_sdk.metrics.timing(key="seer_bootup_time"): - config.do_validation() - initialize_database() - initialize_models(start_model_loading) + # Note: sentry_sdk.metrics.timing was removed in sentry-sdk 2.x + # The metrics API is deprecated and will be fully removed in 3.x + config.do_validation() + initialize_database() + initialize_models(start_model_loading) @inject diff --git a/src/seer/configuration.py b/src/seer/configuration.py index 937ae2c7..6d3aa0f0 100644 --- a/src/seer/configuration.py +++ b/src/seer/configuration.py @@ -67,6 +67,14 @@ class AppConfig(BaseModel): GITHUB_CODECOV_PR_REVIEW_APP_ID: str | None = None GITHUB_CODECOV_PR_REVIEW_PRIVATE_KEY: str | None = None + # GitLab Configuration + GITLAB_TOKEN: str | None = None + GITLAB_INSTANCE_URL: str = "https://gitlab.com" # Override for self-hosted GitLab + + # Anthropic API Configuration (for Explorer) + ANTHROPIC_API_KEY: str | None = None + EXPLORER_MODEL: str = "claude-sonnet-4-20250514" + LANGFUSE_PUBLIC_KEY: str = "" LANGFUSE_SECRET_KEY: str = "" LANGFUSE_HOST: str = "" diff --git a/src/seer/langfuse.py b/src/seer/langfuse.py index 473bd243..1076327f 100644 --- a/src/seer/langfuse.py +++ b/src/seer/langfuse.py @@ -1,7 +1,8 @@ import logging +from typing import Any -from langfuse import Langfuse -from langfuse.decorators import langfuse_context +from langfuse import Langfuse, get_client +from langfuse._client.client import DatasetItemClient # type: ignore[attr-defined] from seer.configuration import AppConfig from seer.dependency_injection import Module, inject, injected @@ -11,13 +12,84 @@ langfuse_module = Module() +# Compatibility functions for langfuse 3.x API changes +def get_dataset_item(langfuse: Langfuse, item_id: str) -> DatasetItemClient: + """ + Compatibility function for langfuse.get_dataset_item() which was removed in 3.x. + In langfuse 3.x, use the API client to fetch dataset items directly. + """ + item = langfuse.api.dataset_items.get(item_id) + return DatasetItemClient(item, langfuse=langfuse) + + +def fetch_trace(langfuse: Langfuse, trace_id: str) -> Any: + """ + Compatibility function for langfuse.fetch_trace() which was removed in 3.x. + In langfuse 3.x, use the API client to fetch traces directly. + """ + return langfuse.api.trace.get(trace_id) + + +class LangfuseContext: + """ + Compatibility layer for langfuse 3.x. + Provides backward-compatible methods that were in langfuse.decorators.langfuse_context. + """ + + def get_current_trace_id(self) -> str | None: + return get_client().get_current_trace_id() + + def get_current_observation_id(self) -> str | None: + return get_client().get_current_observation_id() + + def get_current_trace_url(self) -> str | None: + return get_client().get_trace_url() + + def update_current_trace(self, **kwargs) -> None: + get_client().update_current_trace(**kwargs) + + def update_current_observation( + self, + *, + name: str | None = None, + model: str | None = None, + usage: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + **kwargs, + ) -> None: + """ + Backward-compatible method for updating current observation. + In langfuse 3.x, this maps to update_current_generation for model/usage + or update_current_span for other updates. + """ + client = get_client() + if model is not None or usage is not None: + # Use update_current_generation for model/usage updates + client.update_current_generation( + name=name, + model=model, + usage_details=usage, + metadata=metadata, + ) + else: + # Use update_current_span for other updates + client.update_current_span( + name=name, + metadata=metadata, + ) + + +# Global instance for backward compatibility +langfuse_context = LangfuseContext() + + @langfuse_module.provider def provide_langfuse(config: AppConfig = injected) -> Langfuse: return Langfuse( public_key=config.LANGFUSE_PUBLIC_KEY, secret_key=config.LANGFUSE_SECRET_KEY, host=config.LANGFUSE_HOST, - enabled=bool(config.LANGFUSE_HOST), + tracing_enabled=bool(config.LANGFUSE_HOST), ) @@ -30,7 +102,8 @@ def append_langfuse_trace_tags(new_tags: list[str], langfuse: Langfuse = injecte try: trace_id = langfuse_context.get_current_trace_id() if trace_id: - trace = langfuse.get_trace(trace_id) + # In langfuse 3.x, use api.trace.get() to fetch trace details + trace = langfuse.api.trace.get(trace_id) langfuse_context.update_current_trace( tags=(trace.tags or []) + new_tags, ) diff --git a/tests/automation/agent/_encrypted_cassettes/TestParameterResolutionIntegration.test_parameter_resolution_integration_models_list.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/TestParameterResolutionIntegration.test_parameter_resolution_integration_models_list.yaml.encrypted deleted file mode 100644 index 5be4ad73..00000000 Binary files a/tests/automation/agent/_encrypted_cassettes/TestParameterResolutionIntegration.test_parameter_resolution_integration_models_list.yaml.encrypted and /dev/null differ diff --git a/tests/automation/agent/_encrypted_cassettes/TestParameterResolutionIntegration.test_parameter_resolution_integration_single_model.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/TestParameterResolutionIntegration.test_parameter_resolution_integration_single_model.yaml.encrypted deleted file mode 100644 index 73fa4e0e..00000000 Binary files a/tests/automation/agent/_encrypted_cassettes/TestParameterResolutionIntegration.test_parameter_resolution_integration_single_model.yaml.encrypted and /dev/null differ diff --git a/tests/automation/agent/_encrypted_cassettes/TestParameterResolutionIntegration.test_parameter_resolution_with_structured_generation.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/TestParameterResolutionIntegration.test_parameter_resolution_with_structured_generation.yaml.encrypted deleted file mode 100644 index 69d659ef..00000000 Binary files a/tests/automation/agent/_encrypted_cassettes/TestParameterResolutionIntegration.test_parameter_resolution_with_structured_generation.yaml.encrypted and /dev/null differ diff --git a/tests/automation/agent/_encrypted_cassettes/TestParameterResolutionIntegration.test_parameter_resolution_with_tools.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/TestParameterResolutionIntegration.test_parameter_resolution_with_tools.yaml.encrypted deleted file mode 100644 index 2d48e163..00000000 Binary files a/tests/automation/agent/_encrypted_cassettes/TestParameterResolutionIntegration.test_parameter_resolution_with_tools.yaml.encrypted and /dev/null differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text.yaml.encrypted index 6731c77e..3f1fff62 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_stream.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_stream.yaml.encrypted index 5a040194..124a49fc 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_stream.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_stream.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_stream_with_tools.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_stream_with_tools.yaml.encrypted index e0401640..dc4cc472 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_stream_with_tools.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_stream_with_tools.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_with_models_list.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_with_models_list.yaml.encrypted index 8bdeae47..ec142e5a 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_with_models_list.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_with_models_list.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_with_tools.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_with_tools.yaml.encrypted index 000c3d40..58e770bb 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_with_tools.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_with_tools.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_with_tools_models_list.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_with_tools_models_list.yaml.encrypted index 48e4a46b..c74a8c67 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_with_tools_models_list.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_anthropic_generate_text_with_tools_models_list.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_create_cache_different_ttl.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_create_cache_different_ttl.yaml.encrypted deleted file mode 100644 index bec62ed4..00000000 Binary files a/tests/automation/agent/_encrypted_cassettes/test_create_cache_different_ttl.yaml.encrypted and /dev/null differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_gemini_create_cache.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_gemini_create_cache.yaml.encrypted index f00716f5..70b02e0b 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_gemini_create_cache.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_gemini_create_cache.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_gemini_create_cache_same_display_name.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_gemini_create_cache_same_display_name.yaml.encrypted index 63c18f80..ac20ff81 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_gemini_create_cache_same_display_name.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_gemini_create_cache_same_display_name.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_structured.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_structured.yaml.encrypted index 3ac0c6a8..bcc536da 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_structured.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_structured.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_structured_with_models_list.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_structured_with_models_list.yaml.encrypted index f371da58..c03f169f 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_structured_with_models_list.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_structured_with_models_list.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text.yaml.encrypted index 6f515401..3304385f 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_from_web_search.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_from_web_search.yaml.encrypted index a1f01cc5..7186bade 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_from_web_search.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_from_web_search.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_from_web_search_with_models_list.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_from_web_search_with_models_list.yaml.encrypted index 4a54727e..f621e13d 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_from_web_search_with_models_list.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_from_web_search_with_models_list.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_stream.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_stream.yaml.encrypted index 69109421..58a7904e 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_stream.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_stream.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_stream_with_tools.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_stream_with_tools.yaml.encrypted index b3b04572..8c4b94c1 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_stream_with_tools.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_stream_with_tools.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_with_models_list.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_with_models_list.yaml.encrypted index be513f9d..4585ce79 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_with_models_list.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_with_models_list.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_with_tools.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_with_tools.yaml.encrypted index 563be718..19cfe77a 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_with_tools.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_gemini_generate_text_with_tools.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_gemini_get_cache.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_gemini_get_cache.yaml.encrypted index 0eecf758..a76d10f7 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_gemini_get_cache.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_gemini_get_cache.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_gemini_thinking_config.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_gemini_thinking_config.yaml.encrypted index 7b23d809..24f48357 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_gemini_thinking_config.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_gemini_thinking_config.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_structured.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_structured.yaml.encrypted index bbf24106..50bce163 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_structured.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_structured.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_structured_with_models_list.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_structured_with_models_list.yaml.encrypted index b290d21c..2fa46726 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_structured_with_models_list.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_structured_with_models_list.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text.yaml.encrypted index ea056173..4f7d5f0b 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_stream.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_stream.yaml.encrypted index 9ff10d2c..5628bf08 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_stream.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_stream.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_stream_with_models_list.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_stream_with_models_list.yaml.encrypted index 05db4d3f..257c84e8 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_stream_with_models_list.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_stream_with_models_list.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_stream_with_tools.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_stream_with_tools.yaml.encrypted index 25ffd08d..03c38e05 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_stream_with_tools.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_stream_with_tools.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_with_models_list.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_with_models_list.yaml.encrypted index d565e116..92d7114c 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_with_models_list.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_with_models_list.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_with_tools.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_with_tools.yaml.encrypted index 14548be3..3da0c6eb 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_with_tools.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_with_tools.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_with_tools_models_list.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_with_tools_models_list.yaml.encrypted index 4f33e0d9..1e3c9724 100644 Binary files a/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_with_tools_models_list.yaml.encrypted and b/tests/automation/agent/_encrypted_cassettes/test_openai_generate_text_with_tools_models_list.yaml.encrypted differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_run_iteration.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_run_iteration.yaml.encrypted deleted file mode 100644 index 503bec98..00000000 Binary files a/tests/automation/agent/_encrypted_cassettes/test_run_iteration.yaml.encrypted and /dev/null differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_run_iteration_with_fallback_models.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_run_iteration_with_fallback_models.yaml.encrypted deleted file mode 100644 index 22bf24c5..00000000 Binary files a/tests/automation/agent/_encrypted_cassettes/test_run_iteration_with_fallback_models.yaml.encrypted and /dev/null differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_run_max_iterations_exception.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_run_max_iterations_exception.yaml.encrypted deleted file mode 100644 index 0918bf27..00000000 Binary files a/tests/automation/agent/_encrypted_cassettes/test_run_max_iterations_exception.yaml.encrypted and /dev/null differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_run_with_initial_prompt.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_run_with_initial_prompt.yaml.encrypted deleted file mode 100644 index 5afdd0fb..00000000 Binary files a/tests/automation/agent/_encrypted_cassettes/test_run_with_initial_prompt.yaml.encrypted and /dev/null differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_run_with_initial_prompt_fallback_models.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_run_with_initial_prompt_fallback_models.yaml.encrypted deleted file mode 100644 index fbbb2109..00000000 Binary files a/tests/automation/agent/_encrypted_cassettes/test_run_with_initial_prompt_fallback_models.yaml.encrypted and /dev/null differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_run_with_tool_calls.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_run_with_tool_calls.yaml.encrypted deleted file mode 100644 index 88331da3..00000000 Binary files a/tests/automation/agent/_encrypted_cassettes/test_run_with_tool_calls.yaml.encrypted and /dev/null differ diff --git a/tests/automation/agent/_encrypted_cassettes/test_run_with_tool_calls_fallback_models.yaml.encrypted b/tests/automation/agent/_encrypted_cassettes/test_run_with_tool_calls_fallback_models.yaml.encrypted deleted file mode 100644 index d9396fa1..00000000 Binary files a/tests/automation/agent/_encrypted_cassettes/test_run_with_tool_calls_fallback_models.yaml.encrypted and /dev/null differ diff --git a/tests/automation/agent/test_client.py b/tests/automation/agent/test_client.py index 91861372..01047516 100644 --- a/tests/automation/agent/test_client.py +++ b/tests/automation/agent/test_client.py @@ -84,7 +84,7 @@ def test_openai_generate_text_with_models_list(): @pytest.mark.vcr() def test_anthropic_generate_text(): llm_client = LlmClient() - model = AnthropicProvider.model("claude-3-5-sonnet@20240620") + model = AnthropicProvider.model("claude-sonnet-4@20250514") response = llm_client.generate_text( prompt="Say hello", @@ -92,11 +92,13 @@ def test_anthropic_generate_text(): ) assert isinstance(response, LlmGenerateTextResponse) - assert response.message.content == "Hello! How can I assist you today?" + assert response.message.content is not None and len(response.message.content) > 0 + assert "hello" in response.message.content.lower() assert response.message.role == "assistant" - assert response.metadata.model == "claude-3-5-sonnet@20240620" + assert response.metadata.model == "claude-sonnet-4@20250514" assert response.metadata.provider_name == LlmProviderType.ANTHROPIC - assert response.metadata.usage == Usage(completion_tokens=12, prompt_tokens=9, total_tokens=21) + assert response.metadata.usage.completion_tokens > 0 + assert response.metadata.usage.prompt_tokens > 0 @pytest.mark.vcr() @@ -104,8 +106,8 @@ def test_anthropic_generate_text_with_models_list(): """Test generate_text with Anthropic models list""" llm_client = LlmClient() models = [ - AnthropicProvider.model("claude-3-5-sonnet@20240620"), - AnthropicProvider.model("claude-3-haiku@20240307"), + AnthropicProvider.model("claude-sonnet-4@20250514"), + AnthropicProvider.model("claude-sonnet-4@20250514"), ] response = llm_client.generate_text( @@ -114,11 +116,13 @@ def test_anthropic_generate_text_with_models_list(): ) assert isinstance(response, LlmGenerateTextResponse) - assert response.message.content == "Hello! How can I assist you today?" + assert response.message.content is not None and len(response.message.content) > 0 + assert "hello" in response.message.content.lower() assert response.message.role == "assistant" - assert response.metadata.model == "claude-3-5-sonnet@20240620" + assert response.metadata.model == "claude-sonnet-4@20250514" assert response.metadata.provider_name == LlmProviderType.ANTHROPIC - assert response.metadata.usage == Usage(completion_tokens=12, prompt_tokens=9, total_tokens=21) + assert response.metadata.usage.completion_tokens > 0 + assert response.metadata.usage.prompt_tokens > 0 @pytest.mark.vcr() @@ -193,7 +197,7 @@ def test_openai_generate_text_with_tools_models_list(): @pytest.mark.vcr() def test_anthropic_generate_text_with_tools(): llm_client = LlmClient() - model = AnthropicProvider.model("claude-3-5-sonnet@20240620") + model = AnthropicProvider.model("claude-sonnet-4@20250514") tools = [ FunctionTool( @@ -227,7 +231,7 @@ def test_anthropic_generate_text_with_tools(): def test_anthropic_generate_text_with_tools_models_list(): """Test generate_text with Anthropic tools using models list""" llm_client = LlmClient() - models = [AnthropicProvider.model("claude-3-5-sonnet@20240620")] + models = [AnthropicProvider.model("claude-sonnet-4@20250514")] tools = [ FunctionTool( @@ -610,7 +614,7 @@ def test_openai_generate_text_stream_with_models_list(): @pytest.mark.vcr() def test_anthropic_generate_text_stream(): llm_client = LlmClient() - model = AnthropicProvider.model("claude-3-5-sonnet@20240620") + model = AnthropicProvider.model("claude-sonnet-4@20250514") stream_items = list( llm_client.generate_text_stream( @@ -625,7 +629,9 @@ def test_anthropic_generate_text_stream(): provider_items = [item for item in stream_items if hasattr(item, "provider_name")] assert len(content_chunks) > 0 - assert "".join(content_chunks) == "Hello! How can I assist you today?" + full_content = "".join(content_chunks) + assert len(full_content) > 0 + assert "hello" in full_content.lower() assert len(usage_items) == 1 assert usage_items[0].completion_tokens > 0 assert usage_items[0].prompt_tokens > 0 @@ -636,7 +642,7 @@ def test_anthropic_generate_text_stream(): # Check that the final model used is yielded assert len(provider_items) == 1 - assert provider_items[0].model_name == "claude-3-5-sonnet@20240620" + assert provider_items[0].model_name == "claude-sonnet-4@20250514" assert provider_items[0].provider_name == LlmProviderType.ANTHROPIC @@ -700,7 +706,7 @@ def test_openai_generate_text_stream_with_tools(): @pytest.mark.vcr() def test_anthropic_generate_text_stream_with_tools(): llm_client = LlmClient() - model = AnthropicProvider.model("claude-3-5-sonnet@20240620") + model = AnthropicProvider.model("claude-sonnet-4@20250514") tools = [ FunctionTool( @@ -737,7 +743,7 @@ def test_anthropic_generate_text_stream_with_tools(): # Check that the final model used is yielded assert len(provider_items) == 1 - assert provider_items[0].model_name == "claude-3-5-sonnet@20240620" + assert provider_items[0].model_name == "claude-sonnet-4@20250514" assert provider_items[0].provider_name == LlmProviderType.ANTHROPIC @@ -764,7 +770,7 @@ def test_construct_message_from_stream_openai(): def test_construct_message_from_stream_anthropic(): llm_client = LlmClient() - model = AnthropicProvider.model("claude-3-5-sonnet@20240620") + model = AnthropicProvider.model("claude-sonnet-4@20250514") content_chunks = ["Hello", " world", "!"] tool_calls = [ToolCall(id="123", function="test_function", args='{"x": "test"}')] @@ -1339,7 +1345,7 @@ def test_region_preference_functionality(): ), ] - anthropic_model = AnthropicProvider.model("claude-3-5-sonnet@20240620") + anthropic_model = AnthropicProvider.model("claude-sonnet-4@20250514") test_config = provide_test_defaults() test_config.SENTRY_REGION = "us" @@ -1380,7 +1386,7 @@ def test_region_preference_unknown_sentry_region(): ), ] - anthropic_model = AnthropicProvider.model("claude-3-5-sonnet@20240620") + anthropic_model = AnthropicProvider.model("claude-sonnet-4@20250514") test_config = provide_test_defaults() test_config.SENTRY_REGION = "unknown" @@ -1393,7 +1399,7 @@ def test_region_preference_unknown_sentry_region(): def test_region_preference_de_requires_europe_region(): """Test that DE requires a europe region""" - anthropic_model = AnthropicProvider.model("claude-3-5-sonnet@20240620", region="us-west4") + anthropic_model = AnthropicProvider.model("claude-sonnet-4@20250514", region="us-west4") test_config = provide_test_defaults() test_config.SENTRY_REGION = "de" diff --git a/tests/automation/agent/test_client_fallback.py b/tests/automation/agent/test_client_fallback.py index 5744e390..26dbaf8a 100644 --- a/tests/automation/agent/test_client_fallback.py +++ b/tests/automation/agent/test_client_fallback.py @@ -211,14 +211,14 @@ def test_openai_provider_parameter_resolution(self): def test_anthropic_provider_parameter_resolution(self): """Test parameter resolution works correctly with Anthropic provider model creation""" claude_model = AnthropicProvider.model( - "claude-3-5-sonnet@20240620", + "claude-sonnet-4@20250514", region="us-east-1", temperature=0.7, max_tokens=4000, timeout=90.0, ) - assert claude_model.model_name == "claude-3-5-sonnet@20240620" + assert claude_model.model_name == "claude-sonnet-4@20250514" assert claude_model.region == "us-east-1" assert claude_model.defaults.temperature == 0.7 assert claude_model.defaults.max_tokens == 4000 @@ -255,7 +255,7 @@ def test_provider_specific_parameters(self): # Anthropic-specific parameters anthropic_model = AnthropicProvider.model( - "claude-3-5-sonnet@20240620", + "claude-sonnet-4@20250514", region="us-west-2", # Anthropic-specific region handling timeout=120.0, # Anthropic-specific timeout ) @@ -285,7 +285,7 @@ def test_parameter_resolution_fallback_behavior(self): models = [ OpenAiProvider.model("gpt-4", temperature=0.1, max_tokens=100), OpenAiProvider.model("gpt-3.5-turbo", temperature=0.5, max_tokens=200), - AnthropicProvider.model("claude-3-5-sonnet@20240620", temperature=0.3, max_tokens=150), + AnthropicProvider.model("claude-sonnet-4@20250514", temperature=0.3, max_tokens=150), ] # Each model should maintain its own defaults @@ -337,7 +337,7 @@ def test_mixed_provider_fallback_list(self): # Create models from different providers models = [ OpenAiProvider.model("gpt-4", temperature=0.1), - AnthropicProvider.model("claude-3-5-sonnet@20240620", temperature=0.3), + AnthropicProvider.model("claude-sonnet-4@20250514", temperature=0.3), GeminiProvider.model("gemini-2.0-flash-001", temperature=0.5), ] @@ -358,7 +358,7 @@ def test_region_fallback_behavior(self): llm_client = LlmClient() # Create a model that should have region preferences - base_model = AnthropicProvider.model("claude-3-5-sonnet@20240620") + base_model = AnthropicProvider.model("claude-sonnet-4@20250514") # Mock the get_region_preference to return multiple regions with patch.object( @@ -396,7 +396,7 @@ def test_region_fallback_explicit_region_no_fallback(self): llm_client = LlmClient() # Create a model with explicit region - base_model = AnthropicProvider.model("claude-3-5-sonnet@20240620", region="explicit-region") + base_model = AnthropicProvider.model("claude-sonnet-4@20250514", region="explicit-region") call_count = 0 attempted_regions = [] @@ -422,7 +422,7 @@ def test_region_fallback_multiple_models(self): llm_client = LlmClient() # Create multiple models - model1 = AnthropicProvider.model("claude-3-5-sonnet@20240620") + model1 = AnthropicProvider.model("claude-sonnet-4@20250514") model2 = GeminiProvider.model("gemini-2.0-flash-001") call_count = 0 @@ -434,7 +434,7 @@ def mock_operation(model): operation_calls.append((model.model_name, model.region)) # Fail for first model's regions, succeed on second model's first region - if model.model_name == "claude-3-5-sonnet@20240620": + if model.model_name == "claude-sonnet-4@20250514": from anthropic import RateLimitError raise RateLimitError( @@ -459,8 +459,8 @@ def mock_operation(model): # Should have tried both regions of model1, then first region of model2 expected_calls = [ - ("claude-3-5-sonnet@20240620", "us-east5"), - ("claude-3-5-sonnet@20240620", "global"), + ("claude-sonnet-4@20250514", "us-east5"), + ("claude-sonnet-4@20250514", "global"), ("gemini-2.0-flash-001", "us-central1"), ] assert operation_calls == expected_calls @@ -471,7 +471,7 @@ def test_region_fallback_no_region_preferences(self): llm_client = LlmClient() # Create a model - base_model = AnthropicProvider.model("claude-3-5-sonnet@20240620") + base_model = AnthropicProvider.model("claude-sonnet-4@20250514") call_count = 0 attempted_regions = [] @@ -693,7 +693,7 @@ def setup_method(self): """Set up test providers for each test""" self.llm_client = LlmClient() self.openai_provider = OpenAiProvider.model("gpt-4") - self.anthropic_provider = AnthropicProvider.model("claude-3-5-sonnet@20240620") + self.anthropic_provider = AnthropicProvider.model("claude-sonnet-4@20250514") self.gemini_provider = GeminiProvider.model("gemini-1.5-pro") def test_timeout_exceptions_are_fallback_worthy(self): @@ -1122,7 +1122,7 @@ def setup_method(self): """Set up test client and providers""" self.llm_client = LlmClient() self.openai_provider = OpenAiProvider.model("gpt-4") - self.anthropic_provider = AnthropicProvider.model("claude-3-5-sonnet@20240620") + self.anthropic_provider = AnthropicProvider.model("claude-sonnet-4@20250514") self.gemini_provider = GeminiProvider.model("gemini-1.5-pro") # Create mock response objects for use in tests diff --git a/tests/automation/autofix/_encrypted_cassettes/TestSemanticFileSearch.test_semantic_file_search_completion[repo_names0].yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/TestSemanticFileSearch.test_semantic_file_search_completion[repo_names0].yaml.encrypted deleted file mode 100644 index 9e0da581..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/TestSemanticFileSearch.test_semantic_file_search_completion[repo_names0].yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/TestSemanticFileSearch.test_semantic_file_search_completion[repo_names1].yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/TestSemanticFileSearch.test_semantic_file_search_completion[repo_names1].yaml.encrypted deleted file mode 100644 index 82406a99..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/TestSemanticFileSearch.test_semantic_file_search_completion[repo_names1].yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/TestSemanticFileSearch.test_semantic_file_search_completion[repo_names2].yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/TestSemanticFileSearch.test_semantic_file_search_completion[repo_names2].yaml.encrypted deleted file mode 100644 index fb3d2d30..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/TestSemanticFileSearch.test_semantic_file_search_completion[repo_names2].yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_anthropic_generate_text.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_anthropic_generate_text.yaml.encrypted deleted file mode 100644 index 4721f9d2..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_anthropic_generate_text.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_anthropic_generate_text_stream.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_anthropic_generate_text_stream.yaml.encrypted deleted file mode 100644 index 16129ae8..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_anthropic_generate_text_stream.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_anthropic_generate_text_stream_with_tools.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_anthropic_generate_text_stream_with_tools.yaml.encrypted deleted file mode 100644 index 37a70286..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_anthropic_generate_text_stream_with_tools.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_anthropic_generate_text_with_tools.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_anthropic_generate_text_with_tools.yaml.encrypted deleted file mode 100644 index e1eb48ea..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_anthropic_generate_text_with_tools.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_autofix_create_pr.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_autofix_create_pr.yaml.encrypted deleted file mode 100644 index 71ac547a..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_autofix_create_pr.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_autofix_restart_from_point_with_feedback.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_autofix_restart_from_point_with_feedback.yaml.encrypted deleted file mode 100644 index cf39be2a..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_autofix_restart_from_point_with_feedback.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_coding.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_coding.yaml.encrypted deleted file mode 100644 index 21ca4628..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_coding.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_full.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_full.yaml.encrypted deleted file mode 100644 index 6567ae36..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_full.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_full_with_partial_supported_repos.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_full_with_partial_supported_repos.yaml.encrypted deleted file mode 100644 index 7d2637c7..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_full_with_partial_supported_repos.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_full_without_repos.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_full_without_repos.yaml.encrypted deleted file mode 100644 index 447fa6ec..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_full_without_repos.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_question_asking.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_question_asking.yaml.encrypted deleted file mode 100644 index c6f70e83..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_question_asking.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_root_cause_analysis.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_root_cause_analysis.yaml.encrypted deleted file mode 100644 index 8a76301e..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_autofix_run_root_cause_analysis.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_bad_request_is_not_retried[create_flaky_anthropic].yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_bad_request_is_not_retried[create_flaky_anthropic].yaml.encrypted deleted file mode 100644 index 939bbdbf..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_bad_request_is_not_retried[create_flaky_anthropic].yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_bad_request_is_not_retried[create_flaky_openai].yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_bad_request_is_not_retried[create_flaky_openai].yaml.encrypted deleted file mode 100644 index 4ecefa0a..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_bad_request_is_not_retried[create_flaky_openai].yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_comment_on_thread_creates_new_thread.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_comment_on_thread_creates_new_thread.yaml.encrypted deleted file mode 100644 index 27251c33..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_comment_on_thread_creates_new_thread.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_comment_on_thread_updates_existing_thread.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_comment_on_thread_updates_existing_thread.yaml.encrypted deleted file mode 100644 index a694f0bf..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_comment_on_thread_updates_existing_thread.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_comment_on_thread_with_action_requested.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_comment_on_thread_with_action_requested.yaml.encrypted deleted file mode 100644 index fbe262b1..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_comment_on_thread_with_action_requested.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_flaky_stream[create_flaky_anthropic].yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_flaky_stream[create_flaky_anthropic].yaml.encrypted deleted file mode 100644 index 8dd5ca98..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_flaky_stream[create_flaky_anthropic].yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_flaky_stream[create_flaky_gemini].yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_flaky_stream[create_flaky_gemini].yaml.encrypted deleted file mode 100644 index 3feb0374..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_flaky_stream[create_flaky_gemini].yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_flaky_stream[create_flaky_openai].yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_flaky_stream[create_flaky_openai].yaml.encrypted deleted file mode 100644 index c9c5262c..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_flaky_stream[create_flaky_openai].yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_gemini_generate_text_stream_with_tools.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_gemini_generate_text_stream_with_tools.yaml.encrypted deleted file mode 100644 index e84d6c35..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_gemini_generate_text_stream_with_tools.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_get_completion_interrupts_with_queued_messages.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_get_completion_interrupts_with_queued_messages.yaml.encrypted deleted file mode 100644 index 44319163..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_get_completion_interrupts_with_queued_messages.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_get_completion_interrupts_with_queued_messages_fallback_config.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_get_completion_interrupts_with_queued_messages_fallback_config.yaml.encrypted deleted file mode 100644 index 6b437a09..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_get_completion_interrupts_with_queued_messages_fallback_config.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_max_tries_exceeded[create_flaky_anthropic].yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_max_tries_exceeded[create_flaky_anthropic].yaml.encrypted deleted file mode 100644 index 9b939a56..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_max_tries_exceeded[create_flaky_anthropic].yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_openai_generate_structured.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_openai_generate_structured.yaml.encrypted deleted file mode 100644 index 2c22ede4..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_openai_generate_structured.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_openai_generate_text.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_openai_generate_text.yaml.encrypted deleted file mode 100644 index 2b870df3..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_openai_generate_text.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_openai_generate_text_with_tools.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_openai_generate_text_with_tools.yaml.encrypted deleted file mode 100644 index bd68c842..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_openai_generate_text_with_tools.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_provider_without_exception_indicator[create_flaky_anthropic].yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_provider_without_exception_indicator[create_flaky_anthropic].yaml.encrypted deleted file mode 100644 index e526240a..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_provider_without_exception_indicator[create_flaky_anthropic].yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_restart_step_with_user_response.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_restart_step_with_user_response.yaml.encrypted deleted file mode 100644 index 84764f87..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_restart_step_with_user_response.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_retrying_succeeds[create_flaky_anthropic].yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_retrying_succeeds[create_flaky_anthropic].yaml.encrypted deleted file mode 100644 index 63eb9670..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_retrying_succeeds[create_flaky_anthropic].yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_retrying_succeeds[create_flaky_gemini].yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_retrying_succeeds[create_flaky_gemini].yaml.encrypted deleted file mode 100644 index 9e181ee2..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_retrying_succeeds[create_flaky_gemini].yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_retrying_succeeds[create_flaky_openai].yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_retrying_succeeds[create_flaky_openai].yaml.encrypted deleted file mode 100644 index fed76c66..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_retrying_succeeds[create_flaky_openai].yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_run_iteration.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_run_iteration.yaml.encrypted deleted file mode 100644 index b87af58e..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_run_iteration.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_run_iteration_with_insight_sharing.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_run_iteration_with_insight_sharing.yaml.encrypted deleted file mode 100644 index 23c190bb..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_run_iteration_with_insight_sharing.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_run_iteration_with_queued_user_messages.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_run_iteration_with_queued_user_messages.yaml.encrypted deleted file mode 100644 index 3a4a7f0d..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_run_iteration_with_queued_user_messages.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_run_iteration_with_queued_user_messages_fallback_config.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_run_iteration_with_queued_user_messages_fallback_config.yaml.encrypted deleted file mode 100644 index 40aa0a71..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_run_iteration_with_queued_user_messages_fallback_config.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_run_max_iterations_exception.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_run_max_iterations_exception.yaml.encrypted deleted file mode 100644 index d6054df2..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_run_max_iterations_exception.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_run_with_initial_prompt.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_run_with_initial_prompt.yaml.encrypted deleted file mode 100644 index 7d2139bf..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_run_with_initial_prompt.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_run_with_tool_calls.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_run_with_tool_calls.yaml.encrypted deleted file mode 100644 index 616a82e7..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_run_with_tool_calls.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/_encrypted_cassettes/test_share_insights_no_new_insights.yaml.encrypted b/tests/automation/autofix/_encrypted_cassettes/test_share_insights_no_new_insights.yaml.encrypted deleted file mode 100644 index cd93899e..00000000 Binary files a/tests/automation/autofix/_encrypted_cassettes/test_share_insights_no_new_insights.yaml.encrypted and /dev/null differ diff --git a/tests/automation/autofix/test_autofix_agent.py b/tests/automation/autofix/test_autofix_agent.py index e85a8a04..9ca57d85 100644 --- a/tests/automation/autofix/test_autofix_agent.py +++ b/tests/automation/autofix/test_autofix_agent.py @@ -64,7 +64,7 @@ def fallback_run_config(): prompt="Fix this bug.", models=[ OpenAiProvider.model("gpt-4o-mini-2024-07-18"), - AnthropicProvider.model("claude-3-5-sonnet@20240620"), + AnthropicProvider.model("claude-sonnet-4@20250514"), ], temperature=0.0, run_name="Test Autofix Run with Fallback", diff --git a/tests/automation/autofix/test_autofix_evaluations.py b/tests/automation/autofix/test_autofix_evaluations.py index e17a32f4..9e9ca911 100644 --- a/tests/automation/autofix/test_autofix_evaluations.py +++ b/tests/automation/autofix/test_autofix_evaluations.py @@ -2,7 +2,7 @@ import pytest from johen import generate -from langfuse.client import DatasetItemClient +from langfuse._client.client import DatasetItemClient # type: ignore[attr-defined] from seer.automation.agent.models import ( LlmGenerateTextResponse, diff --git a/tests/automation/codebase/test_gitlab_repo_client.py b/tests/automation/codebase/test_gitlab_repo_client.py new file mode 100644 index 00000000..377fe86b --- /dev/null +++ b/tests/automation/codebase/test_gitlab_repo_client.py @@ -0,0 +1,609 @@ +from unittest.mock import MagicMock, patch + +import gitlab +import pytest + +from seer.automation.codebase.base_repo_client import BranchRefResult, RepoClientType +from seer.automation.codebase.gitlab_repo_client import GitLabRepoClient +from seer.automation.models import RepoDefinition +from seer.configuration import AppConfig +from seer.dependency_injection import resolve + + +@pytest.fixture(autouse=True) +def clear_gitlab_repo_client_cache(): + """Clear the GitLabRepoClient.from_repo_definition cache before each test""" + GitLabRepoClient.from_repo_definition.cache_clear() + yield + + +@pytest.fixture(autouse=True) +def setup_gitlab_config(): + app_config = resolve(AppConfig) + app_config.GITLAB_TOKEN = "test_token" + app_config.GITLAB_INSTANCE_URL = "https://gitlab.com" + yield + + +@pytest.fixture +def mock_gitlab(): + with patch("seer.automation.codebase.gitlab_repo_client.gitlab.Gitlab") as mock: + mock_instance = mock.return_value + mock_project = MagicMock() + mock_project.default_branch = "main" + + # Mock branch for get_branch_head_sha + mock_branch = MagicMock() + mock_branch.commit = {"id": "default_sha"} + mock_project.branches.get.return_value = mock_branch + + mock_instance.projects.get.return_value = mock_project + yield mock_instance + + +@pytest.fixture +def gitlab_repo_definition(): + return RepoDefinition( + provider="gitlab", + owner="test-group", + name="test-project", + external_id="12345", + base_commit_sha="test_sha", + branch_name="test_branch", + ) + + +@pytest.fixture +def gitlab_client(mock_gitlab, gitlab_repo_definition): + return GitLabRepoClient.from_repo_definition(gitlab_repo_definition, RepoClientType.READ) + + +class TestGitLabRepoClient: + + def test_gitlab_client_initialization(self, gitlab_client): + assert gitlab_client.provider == "gitlab" + assert gitlab_client.repo_owner == "test-group" + assert gitlab_client.repo_name == "test-project" + assert gitlab_client.repo_external_id == "12345" + assert gitlab_client.base_commit_sha == "test_sha" + assert gitlab_client.base_branch == "test_branch" + + def test_gitlab_client_initialization_without_base_commit_sha(self, mock_gitlab): + mock_gitlab.projects.get.return_value.branches.get.return_value.commit = { + "id": "default_sha" + } + mock_gitlab.projects.get.return_value.default_branch = "main" + + repo_def_without_sha = RepoDefinition( + provider="gitlab", owner="test-group", name="test-project", external_id="12345" + ) + client = GitLabRepoClient.from_repo_definition(repo_def_without_sha, RepoClientType.READ) + + assert client.base_commit_sha == "default_sha" + assert client.base_branch == "main" + + def test_gitlab_client_rejects_github_provider(self, mock_gitlab): + with pytest.raises(Exception, match="GitLabRepoClient only supports 'gitlab' provider"): + GitLabRepoClient( + "test_token", + RepoDefinition( + provider="github", owner="test-org", name="test-repo", external_id="123" + ), + ) + + def test_gitlab_client_requires_token(self, mock_gitlab): + with patch( + "seer.automation.codebase.gitlab_repo_client.get_gitlab_token", return_value=None + ): + with pytest.raises(Exception, match="No GitLab token provided"): + GitLabRepoClient( + None, + RepoDefinition( + provider="gitlab", + owner="test-group", + name="test-project", + external_id="123", + ), + ) + + def test_get_default_branch(self, gitlab_client, mock_gitlab): + mock_gitlab.projects.get.return_value.default_branch = "develop" + assert gitlab_client.get_default_branch() == "develop" + + def test_get_branch_head_sha(self, gitlab_client, mock_gitlab): + mock_branch = MagicMock() + mock_branch.commit = {"id": "new_sha_12345"} + mock_gitlab.projects.get.return_value.branches.get.return_value = mock_branch + + result = gitlab_client.get_branch_head_sha("feature-branch") + + assert result == "new_sha_12345" + mock_gitlab.projects.get.return_value.branches.get.assert_called_with("feature-branch") + + def test_get_file_content(self, gitlab_client, mock_gitlab): + mock_file = MagicMock() + mock_file.decode.return_value = b"test content" + mock_gitlab.projects.get.return_value.files.get.return_value = mock_file + + content, encoding = gitlab_client.get_file_content("test_file.py") + + assert content == "test content" + mock_gitlab.projects.get.return_value.files.get.assert_called_with( + file_path="test_file.py", ref="test_sha" + ) + + def test_get_file_content_not_found(self, gitlab_client, mock_gitlab): + mock_error = gitlab.exceptions.GitlabGetError() + mock_error.response_code = 404 + mock_gitlab.projects.get.return_value.files.get.side_effect = mock_error + + content, encoding = gitlab_client.get_file_content("nonexistent.py") + + assert content is None + assert encoding == "utf-8" + + def test_get_file_content_strips_leading_slashes(self, gitlab_client, mock_gitlab): + mock_file = MagicMock() + mock_file.decode.return_value = b"content" + mock_gitlab.projects.get.return_value.files.get.return_value = mock_file + + gitlab_client.get_file_content("/path/to/file.py") + + mock_gitlab.projects.get.return_value.files.get.assert_called_with( + file_path="path/to/file.py", ref="test_sha" + ) + + def test_get_valid_file_paths(self, gitlab_client, mock_gitlab): + mock_tree = [ + {"path": "file1.py", "type": "blob"}, + {"path": "file2.py", "type": "blob"}, + {"path": "dir", "type": "tree"}, + {"path": "file3.txt", "type": "blob"}, + ] + mock_gitlab.projects.get.return_value.repository_tree.return_value = mock_tree + + file_paths = gitlab_client.get_valid_file_paths() + + assert "file1.py" in file_paths + assert "file2.py" in file_paths + assert "dir" not in file_paths # directories excluded + + @patch("seer.automation.codebase.gitlab_repo_client.tempfile.mkdtemp") + def test_load_repo_to_tmp_dir(self, mock_mkdtemp, gitlab_client, mock_gitlab, tmp_path): + mock_mkdtemp.return_value = str(tmp_path) + mock_gitlab.projects.get.return_value.repository_archive.return_value = b"archive_content" + + with patch("builtins.open", MagicMock()): + with patch("tarfile.open"): + with patch("os.listdir", return_value=[]): + tmp_dir, tmp_repo_dir = gitlab_client.load_repo_to_tmp_dir() + + assert tmp_dir == str(tmp_path) + assert tmp_repo_dir == str(tmp_path / "repo") + mock_gitlab.projects.get.return_value.repository_archive.assert_called_once_with( + sha="test_sha", format="tar.gz" + ) + + def test_create_branch_from_changes_invalid_input(self, gitlab_client): + with pytest.raises( + ValueError, match="Either file_patches or file_changes must be provided" + ): + gitlab_client.create_branch_from_changes( + pr_title="Test MR", file_patches=None, file_changes=None + ) + + def test_create_branch_from_changes_success(self, gitlab_client, mock_gitlab): + # Mock branch creation + mock_gitlab.projects.get.return_value.branches.create.return_value = MagicMock( + attributes={"name": "test-branch", "commit": {"id": "new_sha"}} + ) + + # Mock commit creation + mock_commit = MagicMock() + mock_commit.id = "commit_sha_123" + mock_gitlab.projects.get.return_value.commits.create.return_value = mock_commit + + # Mock comparison + mock_gitlab.projects.get.return_value.repository_compare.return_value = { + "commits": [{"id": "abc"}], + "diffs": [{"diff": "some diff"}], + } + + # Mock file patch + mock_patch = MagicMock() + mock_patch.path = "test.py" + mock_patch.type = "A" # "A" = Add/Create in git diff format + mock_patch.apply.return_value = "new content" + + result = gitlab_client.create_branch_from_changes( + pr_title="Test MR", file_patches=[mock_patch] + ) + + assert result is not None + assert result.sha == "commit_sha_123" + assert "test-mr" in result.name.lower() + + def test_create_branch_from_changes_branch_exists(self, gitlab_client, mock_gitlab): + # First call raises error for existing branch + mock_error = gitlab.exceptions.GitlabCreateError() + mock_error.response_code = 400 + + mock_gitlab.projects.get.return_value.branches.create.side_effect = [ + mock_error, + MagicMock(attributes={"name": "test-branch-abc123", "commit": {"id": "new_sha"}}), + ] + + # Mock commit creation + mock_commit = MagicMock() + mock_commit.id = "commit_sha_123" + mock_gitlab.projects.get.return_value.commits.create.return_value = mock_commit + + # Mock comparison + mock_gitlab.projects.get.return_value.repository_compare.return_value = { + "commits": [{"id": "abc"}] + } + + # Mock file patch + mock_patch = MagicMock() + mock_patch.path = "test.py" + mock_patch.type = "A" # "A" = Add/Create in git diff format + mock_patch.apply.return_value = "new content" + + result = gitlab_client.create_branch_from_changes( + pr_title="Test MR", file_patches=[mock_patch] + ) + + assert result is not None + # Verify branch creation was called twice (first failed, second with suffix) + assert mock_gitlab.projects.get.return_value.branches.create.call_count == 2 + + def test_create_pr_from_branch_success(self, gitlab_client, mock_gitlab): + branch = BranchRefResult(ref="refs/heads/test-branch", sha="sha123", name="test-branch") + + mock_mr = MagicMock() + mock_mr.iid = 42 + mock_mr.web_url = "https://gitlab.com/test-group/test-project/-/merge_requests/42" + mock_mr.id = 12345 + mock_gitlab.projects.get.return_value.mergerequests.list.return_value = [] + mock_gitlab.projects.get.return_value.mergerequests.create.return_value = mock_mr + + result = gitlab_client.create_pr_from_branch( + branch, title="Test MR", description="Test description" + ) + + assert result.number == 42 + assert result.html_url == "https://gitlab.com/test-group/test-project/-/merge_requests/42" + assert result.id == 12345 + assert result.head_ref == "test-branch" + + def test_create_pr_from_branch_existing_mr(self, gitlab_client, mock_gitlab): + branch = BranchRefResult(ref="refs/heads/test-branch", sha="sha123", name="test-branch") + + mock_existing_mr = MagicMock() + mock_existing_mr.iid = 41 + mock_existing_mr.web_url = "https://gitlab.com/test-group/test-project/-/merge_requests/41" + mock_existing_mr.id = 11111 + mock_gitlab.projects.get.return_value.mergerequests.list.return_value = [mock_existing_mr] + + result = gitlab_client.create_pr_from_branch( + branch, title="Test MR", description="Test description" + ) + + # Should return existing MR + assert result.number == 41 + mock_gitlab.projects.get.return_value.mergerequests.create.assert_not_called() + + def test_create_pr_from_branch_draft_prefix(self, gitlab_client, mock_gitlab): + branch = BranchRefResult(ref="refs/heads/test-branch", sha="sha123", name="test-branch") + + mock_mr = MagicMock() + mock_mr.iid = 42 + mock_mr.web_url = "https://gitlab.com/test-group/test-project/-/merge_requests/42" + mock_mr.id = 12345 + mock_gitlab.projects.get.return_value.mergerequests.list.return_value = [] + mock_gitlab.projects.get.return_value.mergerequests.create.return_value = mock_mr + + gitlab_client.create_pr_from_branch(branch, title="Test MR", description="Description") + + # Verify MR was created with Draft: prefix + call_args = mock_gitlab.projects.get.return_value.mergerequests.create.call_args + assert call_args[0][0]["title"].startswith("Draft:") + + def test_post_issue_comment(self, gitlab_client, mock_gitlab): + mock_mr = MagicMock() + mock_mr.web_url = "https://gitlab.com/test-group/test-project/-/merge_requests/42" + mock_note = MagicMock() + mock_note.id = 999 + mock_mr.notes.create.return_value = mock_note + mock_gitlab.projects.get.return_value.mergerequests.get.return_value = mock_mr + + result = gitlab_client.post_issue_comment( + "https://gitlab.com/test-group/test-project/-/merge_requests/42", + "Test comment", + ) + + assert "#note_999" in result + mock_mr.notes.create.assert_called_once_with({"body": "Test comment"}) + + def test_get_file_url(self, gitlab_client): + url = gitlab_client.get_file_url("src/main.py") + assert "test-group/test-project" in url + assert "test_sha" in url + assert "src/main.py" in url + assert "/-/blob/" in url + + def test_get_file_url_with_lines(self, gitlab_client): + url = gitlab_client.get_file_url("src/main.py", start_line=10, end_line=20) + assert "#L10-20" in url + + def test_get_commit_url(self, gitlab_client): + url = gitlab_client.get_commit_url("abc123") + assert "test-group/test-project" in url + assert "abc123" in url + assert "/-/commit/" in url + + @patch( + "seer.automation.codebase.gitlab_repo_client.get_gitlab_token", return_value="test_token" + ) + @patch("seer.automation.codebase.gitlab_repo_client.gitlab.Gitlab") + def test_check_repo_write_access_success(self, mock_gitlab_class, mock_get_token): + mock_gl = MagicMock() + mock_project = MagicMock() + mock_project.permissions = {"project_access": {"access_level": 40}} # Maintainer + mock_gl.projects.get.return_value = mock_project + mock_gitlab_class.return_value = mock_gl + + result = GitLabRepoClient.check_repo_write_access( + RepoDefinition( + provider="gitlab", owner="test-group", name="test-project", external_id="123" + ) + ) + + assert result is True + + @patch( + "seer.automation.codebase.gitlab_repo_client.get_gitlab_token", return_value="test_token" + ) + @patch("seer.automation.codebase.gitlab_repo_client.gitlab.Gitlab") + def test_check_repo_write_access_insufficient(self, mock_gitlab_class, mock_get_token): + mock_gl = MagicMock() + mock_project = MagicMock() + mock_project.permissions = {"project_access": {"access_level": 20}} # Reporter + mock_gl.projects.get.return_value = mock_project + mock_gitlab_class.return_value = mock_gl + + result = GitLabRepoClient.check_repo_write_access( + RepoDefinition( + provider="gitlab", owner="test-group", name="test-project", external_id="123" + ) + ) + + assert result is False + + @patch("seer.automation.codebase.gitlab_repo_client.get_gitlab_token", return_value=None) + def test_check_repo_write_access_no_token(self, mock_get_token): + result = GitLabRepoClient.check_repo_write_access( + RepoDefinition( + provider="gitlab", owner="test-group", name="test-project", external_id="123" + ) + ) + + assert result is None + + @patch( + "seer.automation.codebase.gitlab_repo_client.get_gitlab_token", return_value="test_token" + ) + @patch("seer.automation.codebase.gitlab_repo_client.gitlab.Gitlab") + def test_check_repo_read_access_success(self, mock_gitlab_class, mock_get_token): + mock_gl = MagicMock() + mock_project = MagicMock() + mock_gl.projects.get.return_value = mock_project + mock_gitlab_class.return_value = mock_gl + + result = GitLabRepoClient.check_repo_read_access( + RepoDefinition( + provider="gitlab", owner="test-group", name="test-project", external_id="123" + ) + ) + + assert result is True + + @patch( + "seer.automation.codebase.gitlab_repo_client.get_gitlab_token", return_value="test_token" + ) + @patch("seer.automation.codebase.gitlab_repo_client.gitlab.Gitlab") + def test_check_repo_read_access_not_found(self, mock_gitlab_class, mock_get_token): + mock_gl = MagicMock() + mock_gl.projects.get.side_effect = gitlab.exceptions.GitlabGetError() + mock_gitlab_class.return_value = mock_gl + + result = GitLabRepoClient.check_repo_read_access( + RepoDefinition( + provider="gitlab", owner="test-group", name="test-project", external_id="123" + ) + ) + + assert result is False + + def test_get_mr_diff_content(self, gitlab_client, mock_gitlab): + mock_mr = MagicMock() + mock_mr.changes.return_value = { + "changes": [ + { + "old_path": "file1.py", + "new_path": "file1.py", + "diff": "@@ -1,5 +1,7 @@\n+new line", + }, + { + "old_path": "file2.py", + "new_path": "file2.py", + "diff": "@@ -10,3 +10,5 @@\n+another", + }, + ] + } + mock_gitlab.projects.get.return_value.mergerequests.get.return_value = mock_mr + + result = gitlab_client.get_mr_diff_content( + "https://gitlab.com/test-group/test-project/-/merge_requests/42" + ) + + assert "file1.py" in result + assert "file2.py" in result + assert "+new line" in result + + def test_get_mr_head_sha(self, gitlab_client, mock_gitlab): + mock_mr = MagicMock() + mock_mr.sha = "head_sha_123" + mock_gitlab.projects.get.return_value.mergerequests.get.return_value = mock_mr + + result = gitlab_client.get_mr_head_sha( + "https://gitlab.com/test-group/test-project/-/merge_requests/42" + ) + + assert result == "head_sha_123" + + def test_autocorrect_path_exact_match(self, gitlab_client): + gitlab_client.get_valid_file_paths = MagicMock( + return_value={"src/main.py", "tests/test.py"} + ) + + path, was_corrected = gitlab_client._autocorrect_path("src/main.py") + + assert path == "src/main.py" + assert was_corrected is False + + def test_autocorrect_path_partial_match(self, gitlab_client): + gitlab_client.get_valid_file_paths = MagicMock( + return_value={"src/main.py", "tests/test.py"} + ) + + path, was_corrected = gitlab_client._autocorrect_path("main.py") + + assert path == "src/main.py" + assert was_corrected is True + + def test_autocorrect_path_no_match(self, gitlab_client): + gitlab_client.get_valid_file_paths = MagicMock( + return_value={"src/main.py", "tests/test.py"} + ) + + path, was_corrected = gitlab_client._autocorrect_path("nonexistent.py") + + assert path == "nonexistent.py" + assert was_corrected is False + + def test_get_commit_history(self, gitlab_client, mock_gitlab): + mock_commit = MagicMock() + mock_commit.id = "abc123def" + mock_commit.message = "Test commit message" + mock_gitlab.projects.get.return_value.commits.list.return_value = [mock_commit] + + # Mock commit detail + mock_commit_detail = MagicMock() + mock_commit_detail.diff.return_value = [ + {"new_path": "test.py", "new_file": False, "deleted_file": False, "renamed_file": False} + ] + mock_gitlab.projects.get.return_value.commits.get.return_value = mock_commit_detail + + result = gitlab_client.get_commit_history("test.py", max_commits=1) + + assert len(result) == 1 + assert "abc123d" in result[0] + assert "Test commit message" in result[0] + + def test_get_commit_patch_for_file(self, gitlab_client, mock_gitlab): + mock_commit = MagicMock() + mock_commit.diff.return_value = [ + {"old_path": "test.py", "new_path": "test.py", "diff": "@@ -1,5 +1,7 @@\n+new line"} + ] + mock_gitlab.projects.get.return_value.commits.get.return_value = mock_commit + + result = gitlab_client.get_commit_patch_for_file("test.py", "commit_sha") + + assert "@@ -1,5 +1,7 @@" in result + + def test_get_commit_patch_for_file_not_found(self, gitlab_client, mock_gitlab): + mock_commit = MagicMock() + mock_commit.diff.return_value = [ + {"old_path": "other.py", "new_path": "other.py", "diff": "some diff"} + ] + mock_gitlab.projects.get.return_value.commits.get.return_value = mock_commit + + result = gitlab_client.get_commit_patch_for_file("test.py", "commit_sha") + + assert result is None + + def test_build_commit_action_for_patch_create(self, gitlab_client): + mock_patch = MagicMock() + mock_patch.path = "new_file.py" + mock_patch.type = "A" # "A" = Add/Create in git diff format + mock_patch.apply.return_value = "new content" + + result = gitlab_client._build_commit_action_for_patch(mock_patch, "main") + + assert result["action"] == "create" + assert result["file_path"] == "new_file.py" + assert result["content"] == "new content" + + def test_build_commit_action_for_patch_update(self, gitlab_client, mock_gitlab): + mock_file = MagicMock() + mock_file.decode.return_value = b"old content" + mock_gitlab.projects.get.return_value.files.get.return_value = mock_file + + mock_patch = MagicMock() + mock_patch.path = "existing.py" + mock_patch.type = "M" # "M" = Modify in git diff format + mock_patch.apply.return_value = "updated content" + + result = gitlab_client._build_commit_action_for_patch(mock_patch, "main") + + assert result["action"] == "update" + assert result["file_path"] == "existing.py" + assert result["content"] == "updated content" + + def test_build_commit_action_for_patch_delete(self, gitlab_client, mock_gitlab): + mock_file = MagicMock() + mock_file.decode.return_value = b"old content" + mock_gitlab.projects.get.return_value.files.get.return_value = mock_file + + mock_patch = MagicMock() + mock_patch.path = "to_delete.py" + mock_patch.type = "D" # "D" = Delete in git diff format + mock_patch.apply.return_value = None + + result = gitlab_client._build_commit_action_for_patch(mock_patch, "main") + + assert result["action"] == "delete" + assert result["file_path"] == "to_delete.py" + assert "content" not in result + + def test_does_file_exist(self, gitlab_client): + gitlab_client.get_valid_file_paths = MagicMock( + return_value={"src/main.py", "tests/test.py"} + ) + + assert gitlab_client.does_file_exist("src/main.py") is True + assert gitlab_client.does_file_exist("/src/main.py") is True + assert gitlab_client.does_file_exist("./src/main.py") is True + assert gitlab_client.does_file_exist("nonexistent.py") is False + + def test_get_branch_ref_success(self, gitlab_client, mock_gitlab): + mock_branch = MagicMock() + mock_branch.commit = {"id": "sha123456"} + mock_gitlab.projects.get.return_value.branches.get.return_value = mock_branch + + result = gitlab_client.get_branch_ref("feature-branch") + + assert result is not None + assert result.name == "feature-branch" + assert result.sha == "sha123456" + assert result.ref == "refs/heads/feature-branch" + + def test_get_branch_ref_not_found(self, gitlab_client, mock_gitlab): + mock_error = gitlab.exceptions.GitlabGetError() + mock_error.response_code = 404 + mock_gitlab.projects.get.return_value.branches.get.side_effect = mock_error + + result = gitlab_client.get_branch_ref("nonexistent-branch") + + assert result is None diff --git a/tests/automation/summarize/_encrypted_cassettes/TestSummarizeIssue.test_summarize_issue_success.yaml.encrypted b/tests/automation/summarize/_encrypted_cassettes/TestSummarizeIssue.test_summarize_issue_success.yaml.encrypted index cc1f9c52..e071572b 100644 Binary files a/tests/automation/summarize/_encrypted_cassettes/TestSummarizeIssue.test_summarize_issue_success.yaml.encrypted and b/tests/automation/summarize/_encrypted_cassettes/TestSummarizeIssue.test_summarize_issue_success.yaml.encrypted differ diff --git a/tests/seer/automation/explorer/__init__.py b/tests/seer/automation/explorer/__init__.py new file mode 100644 index 00000000..a96ce38d --- /dev/null +++ b/tests/seer/automation/explorer/__init__.py @@ -0,0 +1 @@ +# Tests for the Explorer module diff --git a/tests/seer/automation/explorer/test_agent.py b/tests/seer/automation/explorer/test_agent.py new file mode 100644 index 00000000..894906fc --- /dev/null +++ b/tests/seer/automation/explorer/test_agent.py @@ -0,0 +1,269 @@ +"""Tests for Explorer agent.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from seer.automation.explorer.agent import ExplorerAgent +from seer.automation.explorer.models import ExplorerStatus, Message, ToolDefinition +from seer.automation.explorer.state import ExplorerRunState +from seer.db import DbRunMemory, DbRunState, Session + + +@pytest.fixture +def cleanup_explorer_runs(): + """Clean up explorer runs after tests.""" + yield + with Session() as session: + session.query(DbRunMemory).delete() + session.query(DbRunState).filter(DbRunState.type == "explorer").delete() + session.commit() + + +@pytest.fixture +def mock_anthropic_response(): + """Create a mock Anthropic response.""" + mock_response = MagicMock() + mock_response.content = [MagicMock(type="text", text="This is a test response.")] + return mock_response + + +@pytest.fixture +def mock_anthropic_client(mock_anthropic_response): + """Create a mock Anthropic client.""" + mock_client = MagicMock() + mock_client.messages.create.return_value = mock_anthropic_response + return mock_client + + +class TestExplorerAgent: + def test_process_message_success(self, cleanup_explorer_runs, mock_anthropic_client): + """Test processing a message successfully.""" + state = ExplorerRunState.create(organization_id=1) + agent = ExplorerAgent(state=state, organization_id=1) + agent._client = mock_anthropic_client + + run_id = agent.process_message(query="What's causing this error?") + + assert run_id == state.run_id + + # Verify state was updated + run_state = state.get_state() + assert run_state.status == ExplorerStatus.COMPLETED + assert len(run_state.blocks) == 2 # User message + assistant response + + # Verify messages + memory = state.get_memory() + assert memory[0].message.role == "user" + assert memory[0].message.content == "What's causing this error?" + assert memory[1].message.role == "assistant" + assert memory[1].message.content == "This is a test response." + + def test_process_message_with_metadata(self, cleanup_explorer_runs, mock_anthropic_client): + """Test processing a message with metadata context.""" + state = ExplorerRunState.create(organization_id=1) + agent = ExplorerAgent(state=state, organization_id=1) + agent._client = mock_anthropic_client + + metadata = { + "issue": {"id": 123, "title": "NullPointerException"}, + "stacktrace": "at com.example.Main.run(Main.java:42)", + } + + agent.process_message(query="Help me fix this", metadata=metadata) + + # Verify the API was called (system prompt should include context) + mock_anthropic_client.messages.create.assert_called_once() + call_args = mock_anthropic_client.messages.create.call_args + assert "Issue Context" in call_args.kwargs["system"] + + def test_process_message_with_tools(self, cleanup_explorer_runs, mock_anthropic_client): + """Test processing a message with custom tools.""" + state = ExplorerRunState.create(organization_id=1) + agent = ExplorerAgent(state=state, organization_id=1) + agent._client = mock_anthropic_client + + tools = [ + ToolDefinition( + name="search_code", + description="Search the codebase", + param_schema={"type": "object", "properties": {"query": {"type": "string"}}}, + ) + ] + + agent.process_message(query="Find the bug", tools=tools) + + # Verify tools were passed to API + call_args = mock_anthropic_client.messages.create.call_args + assert call_args.kwargs["tools"] is not None + assert len(call_args.kwargs["tools"]) == 1 + assert call_args.kwargs["tools"][0]["name"] == "search_code" + + def test_process_message_error(self, cleanup_explorer_runs, mock_anthropic_client): + """Test error handling during message processing.""" + state = ExplorerRunState.create(organization_id=1) + agent = ExplorerAgent(state=state, organization_id=1) + agent._client = mock_anthropic_client + + # Make the API call fail + mock_anthropic_client.messages.create.side_effect = Exception("API Error") + + agent.process_message(query="Help") + + # Verify error state + run_state = state.get_state() + assert run_state.status == ExplorerStatus.ERROR + + # Verify error message was added + memory = state.get_memory() + assert len(memory) == 2 # User message + error message + assert "error" in memory[1].message.content.lower() + + def test_process_message_with_tool_calls(self, cleanup_explorer_runs): + """Test processing a response that includes tool calls.""" + state = ExplorerRunState.create(organization_id=1) + agent = ExplorerAgent(state=state, organization_id=1) + + # Mock response with tool use + mock_response = MagicMock() + mock_tool_use = MagicMock() + mock_tool_use.type = "tool_use" + mock_tool_use.id = "tool_123" + mock_tool_use.name = "search_code" + mock_tool_use.input = {"query": "null pointer"} + mock_response.content = [mock_tool_use] + + mock_client = MagicMock() + mock_client.messages.create.return_value = mock_response + agent._client = mock_client + + agent.process_message(query="Find the issue") + + # Verify tool calls were recorded + memory = state.get_memory() + assistant_msg = memory[1].message + assert assistant_msg.tool_calls is not None + assert len(assistant_msg.tool_calls) == 1 + assert assistant_msg.tool_calls[0].function == "search_code" + + def test_build_messages(self, cleanup_explorer_runs): + """Test building Claude messages from history.""" + state = ExplorerRunState.create(organization_id=1) + + # Add some messages to history + state.add_message(Message(role="user", content="Hello")) + state.add_message(Message(role="assistant", content="Hi there!")) + state.add_message(Message(role="user", content="What's wrong?")) + + agent = ExplorerAgent(state=state) + messages = agent._build_messages() + + assert len(messages) == 3 + assert messages[0]["role"] == "user" + assert messages[0]["content"] == "Hello" + assert messages[1]["role"] == "assistant" + assert messages[2]["role"] == "user" + + def test_extract_artifact_json_block(self, cleanup_explorer_runs): + """Test extracting artifact from JSON code block.""" + state = ExplorerRunState.create(organization_id=1) + agent = ExplorerAgent(state=state) + + response_text = """ +Here is the analysis: + +```json +{ + "cause": "Null pointer dereference", + "file": "Main.java", + "line": 42 +} +``` + +The fix is to add a null check. +""" + + artifact_schema = { + "type": "object", + "properties": { + "cause": {"type": "string"}, + "file": {"type": "string"}, + }, + "required": ["cause"], + } + + artifact = agent._extract_artifact(response_text, "root_cause", artifact_schema) + + assert artifact is not None + assert artifact.key == "root_cause" + assert artifact.data["cause"] == "Null pointer dereference" + assert artifact.data["file"] == "Main.java" + + def test_extract_artifact_no_json(self, cleanup_explorer_runs): + """Test artifact extraction with no JSON block.""" + state = ExplorerRunState.create(organization_id=1) + agent = ExplorerAgent(state=state) + + response_text = "This response has no JSON blocks." + artifact_schema = {"type": "object", "properties": {}} + + artifact = agent._extract_artifact(response_text, "test", artifact_schema) + assert artifact is None + + def test_extract_artifact_invalid_json(self, cleanup_explorer_runs): + """Test artifact extraction with invalid JSON.""" + state = ExplorerRunState.create(organization_id=1) + agent = ExplorerAgent(state=state) + + response_text = """ +```json +{invalid json here} +``` +""" + artifact_schema = {"type": "object", "properties": {}} + + artifact = agent._extract_artifact(response_text, "test", artifact_schema) + assert artifact is None + + +class TestExplorerAgentConfiguration: + @patch.dict("os.environ", {"EXPLORER_MODEL": "claude-sonnet-4-20250514"}) + def test_get_model_from_config(self, cleanup_explorer_runs): + """Test getting model name from configuration.""" + state = ExplorerRunState.create(organization_id=1) + agent = ExplorerAgent(state=state) + + # The model should come from environment + model = agent._get_model() + assert model is not None + assert "claude" in model.lower() + + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}) + def test_client_creation(self, cleanup_explorer_runs): + """Test Anthropic client is created with API key.""" + state = ExplorerRunState.create(organization_id=1) + ExplorerAgent(state=state) + + # Test that API key can be retrieved from environment + mock_config = MagicMock() + mock_config.ANTHROPIC_API_KEY = "test-key" + + # Directly test client creation logic + api_key = mock_config.ANTHROPIC_API_KEY or os.environ.get("ANTHROPIC_API_KEY") + assert api_key == "test-key" + + def test_client_missing_api_key(self, cleanup_explorer_runs): + """Test error when API key is missing.""" + state = ExplorerRunState.create(organization_id=1) + agent = ExplorerAgent(state=state) + agent._client = None + + with patch.dict("os.environ", {}, clear=True): + with patch("seer.dependency_injection.resolve") as mock_resolve: + mock_config = MagicMock() + mock_config.ANTHROPIC_API_KEY = None + mock_resolve.return_value = mock_config + + with pytest.raises(ValueError, match="ANTHROPIC_API_KEY"): + _ = agent.client diff --git a/tests/seer/automation/explorer/test_endpoints.py b/tests/seer/automation/explorer/test_endpoints.py new file mode 100644 index 00000000..b6d0eb6a --- /dev/null +++ b/tests/seer/automation/explorer/test_endpoints.py @@ -0,0 +1,243 @@ +"""Tests for Explorer API endpoints.""" + +from unittest.mock import patch + +import pytest +from flask import Flask + +from seer.automation.explorer.models import ExplorerStatus +from seer.automation.explorer.state import ExplorerRunState as ExplorerRunStateClass +from seer.configuration import AppConfig +from seer.db import DbRunMemory, DbRunState, Session +from seer.dependency_injection import resolve + + +@pytest.fixture +def cleanup_explorer_runs(): + """Clean up explorer runs after tests.""" + yield + with Session() as session: + session.query(DbRunMemory).delete() + session.query(DbRunState).filter(DbRunState.type == "explorer").delete() + session.commit() + + +@pytest.fixture +def app(): + """Create Flask test app.""" + from seer.app import blueprint + + app = Flask(__name__) + app.register_blueprint(blueprint) + return app + + +@pytest.fixture +def client(app): + """Create test client.""" + app_config = resolve(AppConfig) + app_config.IGNORE_API_AUTH = True + return app.test_client() + + +class TestExplorerRunsEndpoint: + def test_list_runs_empty(self, client, cleanup_explorer_runs): + """Test listing runs when none exist.""" + response = client.post( + "/v1/automation/explorer/runs", + json={"organization_id": 1}, + ) + assert response.status_code == 200 + data = response.get_json() + assert data["runs"] == [] + + def test_list_runs_with_results(self, client, cleanup_explorer_runs): + """Test listing runs with results.""" + # Create some runs + ExplorerRunStateClass.create(organization_id=1) + ExplorerRunStateClass.create(organization_id=1) + + response = client.post( + "/v1/automation/explorer/runs", + json={"organization_id": 1}, + ) + assert response.status_code == 200 + data = response.get_json() + assert len(data["runs"]) == 2 + + +class TestExplorerChatEndpoint: + def test_chat_no_api_key(self, client, cleanup_explorer_runs): + """Test chat endpoint without API key configured.""" + app_config = resolve(AppConfig) + original_key = app_config.ANTHROPIC_API_KEY + app_config.ANTHROPIC_API_KEY = None + + with patch.dict("os.environ", {}, clear=True): + with patch.dict("os.environ", {"DATABASE_URL": "test", "CELERY_BROKER_URL": "test"}): + response = client.post( + "/v1/automation/explorer/chat", + json={ + "organization_id": 1, + "query": "Help me", + }, + ) + + # Restore + app_config.ANTHROPIC_API_KEY = original_key + + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "not_available" + assert "ANTHROPIC_API_KEY" in data["message"] + + @patch("seer.app.process_explorer_chat") + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}) + def test_chat_new_run(self, mock_task, client, cleanup_explorer_runs): + """Test starting a new chat run.""" + app_config = resolve(AppConfig) + app_config.ANTHROPIC_API_KEY = "test-key" + + response = client.post( + "/v1/automation/explorer/chat", + json={ + "organization_id": 1, + "query": "What's wrong?", + }, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "processing" + assert data["run_id"] is not None + + # Verify task was queued + mock_task.delay.assert_called_once() + + @patch("seer.app.process_explorer_chat") + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}) + def test_chat_existing_run(self, mock_task, client, cleanup_explorer_runs): + """Test continuing an existing chat run.""" + app_config = resolve(AppConfig) + app_config.ANTHROPIC_API_KEY = "test-key" + + # Create a run first + state = ExplorerRunStateClass.create(organization_id=1) + + response = client.post( + "/v1/automation/explorer/chat", + json={ + "run_id": state.run_id, + "query": "More help please", + }, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "processing" + assert data["run_id"] == state.run_id + + @patch("seer.app.process_explorer_chat") + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}) + def test_chat_run_not_found(self, mock_task, client, cleanup_explorer_runs): + """Test chat with non-existent run ID.""" + app_config = resolve(AppConfig) + app_config.ANTHROPIC_API_KEY = "test-key" + + response = client.post( + "/v1/automation/explorer/chat", + json={ + "run_id": 999999, + "query": "Help", + }, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "error" + assert "not found" in data["message"] + + @patch("seer.app.process_explorer_chat") + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}) + def test_chat_no_query(self, mock_task, client, cleanup_explorer_runs): + """Test chat without query.""" + app_config = resolve(AppConfig) + app_config.ANTHROPIC_API_KEY = "test-key" + + response = client.post( + "/v1/automation/explorer/chat", + json={ + "organization_id": 1, + # No query provided + }, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "error" + assert "No query" in data["message"] + + +class TestExplorerStateEndpoint: + def test_get_state_success(self, client, cleanup_explorer_runs): + """Test getting run state.""" + state = ExplorerRunStateClass.create(organization_id=1) + + response = client.post( + "/v1/automation/explorer/state", + json={"run_id": state.run_id}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "ok" + assert data["session"]["run_id"] == state.run_id + + def test_get_state_not_found(self, client, cleanup_explorer_runs): + """Test getting non-existent run state.""" + response = client.post( + "/v1/automation/explorer/state", + json={"run_id": 999999}, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "not_found" + assert data["session"] is None + + +class TestExplorerUpdateEndpoint: + def test_update_cancel(self, client, cleanup_explorer_runs): + """Test canceling a run.""" + state = ExplorerRunStateClass.create(organization_id=1) + + response = client.post( + "/v1/automation/explorer/update", + json={ + "run_id": state.run_id, + "update_type": "cancel", + }, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "ok" + + # Verify run was canceled + run_state = state.get_state() + assert run_state.status == ExplorerStatus.COMPLETED + + def test_update_not_found(self, client, cleanup_explorer_runs): + """Test updating non-existent run.""" + response = client.post( + "/v1/automation/explorer/update", + json={ + "run_id": 999999, + "update_type": "cancel", + }, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "error" + assert "not found" in data["message"] diff --git a/tests/seer/automation/explorer/test_models.py b/tests/seer/automation/explorer/test_models.py new file mode 100644 index 00000000..58fa4aae --- /dev/null +++ b/tests/seer/automation/explorer/test_models.py @@ -0,0 +1,209 @@ +"""Tests for Explorer models.""" + +from seer.automation.explorer.models import ( + Artifact, + ConduitParams, + ExplorerChatRequest, + ExplorerChatResponse, + ExplorerStateResponse, + ExplorerStatus, + MemoryBlock, + Message, + SeerRunState, + ToolCall, + ToolDefinition, +) + + +class TestExplorerStatus: + def test_terminal_statuses(self): + terminal = ExplorerStatus.terminal() + assert ExplorerStatus.COMPLETED in terminal + assert ExplorerStatus.ERROR in terminal + assert ExplorerStatus.PROCESSING not in terminal + assert ExplorerStatus.AWAITING_USER_INPUT not in terminal + + +class TestMessage: + def test_user_message(self): + msg = Message(role="user", content="Hello") + assert msg.role == "user" + assert msg.content == "Hello" + assert msg.tool_calls is None + + def test_assistant_message_with_tool_calls(self): + tool_call = ToolCall(id="tc_1", function="search", args='{"query": "test"}') + msg = Message(role="assistant", content="Let me search", tool_calls=[tool_call]) + assert msg.role == "assistant" + assert len(msg.tool_calls) == 1 + assert msg.tool_calls[0].function == "search" + + +class TestMemoryBlock: + def test_memory_block_creation(self): + msg = Message(role="user", content="Test") + block = MemoryBlock(message=msg) + assert block.message.content == "Test" + assert block.loading is False + assert block.artifacts == [] + assert block.id is not None # Auto-generated UUID + + def test_memory_block_with_artifacts(self): + msg = Message(role="assistant", content="Result") + artifact = Artifact(key="root_cause", data={"cause": "null pointer"}, reason="Found") + block = MemoryBlock(message=msg, artifacts=[artifact]) + assert len(block.artifacts) == 1 + assert block.artifacts[0].key == "root_cause" + + +class TestSeerRunState: + def test_minimal_state(self): + state = SeerRunState(run_id=1) + assert state.run_id == 1 + assert state.status == ExplorerStatus.PROCESSING + assert state.blocks == [] + + def test_full_state(self): + msg = Message(role="user", content="Help me") + block = MemoryBlock(message=msg) + state = SeerRunState( + run_id=123, + blocks=[block], + status=ExplorerStatus.COMPLETED, + metadata={"org_id": 1}, + ) + assert state.run_id == 123 + assert len(state.blocks) == 1 + assert state.status == ExplorerStatus.COMPLETED + + +class TestExplorerChatRequest: + def test_basic_request(self): + req = ExplorerChatRequest( + organization_id=1, + query="What's wrong?", + ) + assert req.get_query() == "What's wrong?" + assert req.run_id is None + + def test_request_with_message_alias(self): + req = ExplorerChatRequest( + organization_id=1, + message="What's wrong?", # Using message instead of query + ) + assert req.get_query() == "What's wrong?" + + def test_request_with_tools(self): + tool = ToolDefinition( + name="search_code", + description="Search the codebase", + param_schema={"type": "object", "properties": {"query": {"type": "string"}}}, + ) + req = ExplorerChatRequest( + organization_id=1, + query="Find the bug", + tools=[tool], + ) + assert len(req.tools) == 1 + assert req.tools[0].name == "search_code" + + def test_request_with_conduit(self): + req = ExplorerChatRequest( + organization_id=1, + query="Help", + conduit=ConduitParams(channel_id="ch_123", url="wss://conduit.example.com"), + ) + assert req.conduit.channel_id == "ch_123" + + +class TestExplorerChatResponse: + def test_processing_response(self): + resp = ExplorerChatResponse(status="processing", run_id=123) + assert resp.status == "processing" + assert resp.run_id == 123 + + def test_error_response(self): + resp = ExplorerChatResponse(status="error", message="Something went wrong") + assert resp.status == "error" + assert resp.message == "Something went wrong" + + +class TestExplorerStateResponse: + def test_with_session(self): + state = SeerRunState(run_id=1) + resp = ExplorerStateResponse(session=state, status="ok") + assert resp.session.run_id == 1 + assert resp.status == "ok" + + def test_not_found(self): + resp = ExplorerStateResponse(session=None, status="not_found", message="Run not found") + assert resp.session is None + assert resp.status == "not_found" + + +class TestToolDefinition: + def test_basic_tool(self): + tool = ToolDefinition(name="test_tool", description="A test tool") + assert tool.name == "test_tool" + assert tool.module_path is None + assert tool.param_schema is None + + def test_full_tool(self): + tool = ToolDefinition( + name="search", + module_path="sentry.tools.search.SearchTool", + description="Search for code", + param_schema={ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + assert tool.module_path == "sentry.tools.search.SearchTool" + assert "required" in tool.param_schema + + +class TestArtifact: + def test_basic_artifact(self): + artifact = Artifact(key="root_cause") + assert artifact.key == "root_cause" + assert artifact.data is None + assert artifact.reason == "" + + def test_artifact_with_data(self): + artifact = Artifact( + key="solution", + data={"fix": "Add null check", "confidence": 0.9}, + reason="Based on stack trace analysis", + ) + assert artifact.data["fix"] == "Add null check" + assert artifact.reason == "Based on stack trace analysis" + + +class TestSerializationRoundtrip: + def test_memory_block_roundtrip(self): + msg = Message( + role="assistant", content="Test", tool_calls=[ToolCall(function="f", args="{}")] + ) + artifact = Artifact(key="k", data={"v": 1}) + block = MemoryBlock(message=msg, artifacts=[artifact]) + + # Serialize and deserialize + data = block.model_dump(mode="json") + restored = MemoryBlock.model_validate(data) + + assert restored.message.content == "Test" + assert restored.artifacts[0].key == "k" + + def test_seer_run_state_roundtrip(self): + state = SeerRunState( + run_id=42, + status=ExplorerStatus.COMPLETED, + metadata={"org": 1}, + ) + + data = state.model_dump(mode="json") + restored = SeerRunState.model_validate(data) + + assert restored.run_id == 42 + assert restored.status == ExplorerStatus.COMPLETED diff --git a/tests/seer/automation/explorer/test_prompts.py b/tests/seer/automation/explorer/test_prompts.py new file mode 100644 index 00000000..8c87b0b0 --- /dev/null +++ b/tests/seer/automation/explorer/test_prompts.py @@ -0,0 +1,51 @@ +"""Tests for Explorer prompts.""" + +from seer.automation.explorer.prompts import ( + ARTIFACT_INSTRUCTIONS_DEFAULT, + ARTIFACT_INSTRUCTIONS_WITH_SCHEMA, + EXPLORER_SYSTEM_PROMPT, + get_explorer_system_prompt, +) + + +class TestExplorerSystemPrompt: + def test_base_prompt_structure(self): + """Test that base prompt has required sections.""" + assert "Seer" in EXPLORER_SYSTEM_PROMPT + assert "Capabilities" in EXPLORER_SYSTEM_PROMPT + assert "Goals" in EXPLORER_SYSTEM_PROMPT + assert "{artifact_instructions}" in EXPLORER_SYSTEM_PROMPT + + def test_get_prompt_default(self): + """Test getting prompt without artifact parameters.""" + prompt = get_explorer_system_prompt() + + assert "Seer" in prompt + assert ARTIFACT_INSTRUCTIONS_DEFAULT in prompt + assert "{artifact_instructions}" not in prompt # Should be replaced + + def test_get_prompt_with_artifact(self): + """Test getting prompt with artifact schema.""" + artifact_schema = { + "type": "object", + "properties": {"cause": {"type": "string"}}, + "required": ["cause"], + } + + prompt = get_explorer_system_prompt( + artifact_key="root_cause", + artifact_schema=artifact_schema, + ) + + assert "root_cause" in prompt + assert "cause" in prompt + assert ARTIFACT_INSTRUCTIONS_DEFAULT not in prompt + + def test_artifact_instructions_default(self): + """Test default artifact instructions.""" + assert "structured artifact" in ARTIFACT_INSTRUCTIONS_DEFAULT.lower() + + def test_artifact_instructions_with_schema(self): + """Test artifact instructions template has placeholders.""" + assert "{artifact_key}" in ARTIFACT_INSTRUCTIONS_WITH_SCHEMA + assert "{artifact_schema}" in ARTIFACT_INSTRUCTIONS_WITH_SCHEMA diff --git a/tests/seer/automation/explorer/test_state.py b/tests/seer/automation/explorer/test_state.py new file mode 100644 index 00000000..a97db481 --- /dev/null +++ b/tests/seer/automation/explorer/test_state.py @@ -0,0 +1,189 @@ +"""Tests for Explorer state management.""" + +import pytest + +from seer.automation.explorer.models import Artifact, ExplorerStatus, Message, SeerRunState +from seer.automation.explorer.state import DbStateRunTypes, ExplorerRunState +from seer.db import DbRunMemory, DbRunState, Session + + +@pytest.fixture +def cleanup_explorer_runs(): + """Clean up explorer runs after tests.""" + yield + with Session() as session: + session.query(DbRunMemory).delete() + session.query(DbRunState).filter(DbRunState.type == "explorer").delete() + session.commit() + + +class TestExplorerRunState: + def test_create_run(self, cleanup_explorer_runs): + """Test creating a new explorer run.""" + state = ExplorerRunState.create( + organization_id=123, + category_key="issue", + category_value="456", + metadata={"extra": "data"}, + ) + + assert state.run_id is not None + assert state.run_id > 0 + + # Verify state was persisted + retrieved = ExplorerRunState.get(state.run_id) + assert retrieved is not None + + run_state = retrieved.get_state() + assert run_state.run_id == state.run_id + assert run_state.status == ExplorerStatus.PROCESSING + assert run_state.metadata["organization_id"] == 123 + assert run_state.metadata["category_key"] == "issue" + + def test_get_nonexistent_run(self, cleanup_explorer_runs): + """Test getting a run that doesn't exist.""" + result = ExplorerRunState.get(999999) + assert result is None + + def test_get_wrong_type(self, cleanup_explorer_runs): + """Test getting a run with wrong type returns None.""" + # Create a run with a different type + with Session() as session: + db_state = DbRunState( + value={"run_id": -1}, + type="autofix", # Different type + ) + session.add(db_state) + session.commit() + wrong_id = db_state.id + + result = ExplorerRunState.get(wrong_id) + assert result is None + + def test_list_runs(self, cleanup_explorer_runs): + """Test listing explorer runs.""" + # Create some runs + state1 = ExplorerRunState.create( + organization_id=1, category_key="issue", category_value="100" + ) + ExplorerRunState.create(organization_id=1, category_key="issue", category_value="200") + ExplorerRunState.create(organization_id=2, category_key="issue", category_value="300") + + # List all runs for org 1 + runs = ExplorerRunState.list(organization_id=1) + assert len(runs) == 2 + + # List runs for specific category + runs = ExplorerRunState.list(organization_id=1, category_value="100") + assert len(runs) == 1 + assert runs[0].run_id == state1.run_id + + def test_add_message(self, cleanup_explorer_runs): + """Test adding messages to conversation history.""" + state = ExplorerRunState.create(organization_id=1) + + # Add user message + user_msg = Message(role="user", content="What's wrong?") + block1 = state.add_message(user_msg) + assert block1.message.content == "What's wrong?" + + # Add assistant response + assistant_msg = Message(role="assistant", content="The issue is...") + state.add_message(assistant_msg) + + # Verify history + memory = state.get_memory() + assert len(memory) == 2 + assert memory[0].message.role == "user" + assert memory[1].message.role == "assistant" + + def test_add_message_with_artifacts(self, cleanup_explorer_runs): + """Test adding messages with artifacts.""" + state = ExplorerRunState.create(organization_id=1) + + msg = Message(role="assistant", content="Found the root cause") + artifact = Artifact(key="root_cause", data={"cause": "null pointer"}) + block = state.add_message(msg, artifacts=[artifact]) + + assert len(block.artifacts) == 1 + assert block.artifacts[0].key == "root_cause" + + # Verify in memory + memory = state.get_memory() + assert len(memory[0].artifacts) == 1 + + def test_set_artifact(self, cleanup_explorer_runs): + """Test setting an artifact on the most recent message.""" + state = ExplorerRunState.create(organization_id=1) + + # Add a message first + msg = Message(role="assistant", content="Analyzing...") + state.add_message(msg) + + # Set artifact + artifact = state.set_artifact("solution", {"fix": "Add null check"}, reason="Found fix") + + assert artifact.key == "solution" + assert artifact.data["fix"] == "Add null check" + + def test_set_status(self, cleanup_explorer_runs): + """Test setting run status.""" + state = ExplorerRunState.create(organization_id=1) + + assert state.get_state().status == ExplorerStatus.PROCESSING + + state.set_status(ExplorerStatus.COMPLETED) + assert state.get_state().status == ExplorerStatus.COMPLETED + + state.set_status(ExplorerStatus.ERROR) + assert state.get_state().status == ExplorerStatus.ERROR + + def test_set_loading(self, cleanup_explorer_runs): + """Test setting loading state.""" + state = ExplorerRunState.create(organization_id=1) + + # Add a message + msg = Message(role="assistant", content="Working...") + state.add_message(msg) + + # Set loading + state.set_loading(True) + run_state = state.get_state() + assert run_state.blocks[-1].loading is True + + state.set_loading(False) + run_state = state.get_state() + assert run_state.blocks[-1].loading is False + + def test_update_context_manager(self, cleanup_explorer_runs): + """Test atomic updates via context manager.""" + state = ExplorerRunState.create(organization_id=1) + + with state.update() as run_state: + run_state.status = ExplorerStatus.COMPLETED + run_state.metadata["updated"] = True + + # Verify changes persisted + updated = state.get_state() + assert updated.status == ExplorerStatus.COMPLETED + assert updated.metadata["updated"] is True + + def test_to_seer_run_state(self, cleanup_explorer_runs): + """Test converting to SeerRunState for API response.""" + state = ExplorerRunState.create(organization_id=1, metadata={"test": True}) + + msg = Message(role="user", content="Hello") + state.add_message(msg) + state.set_status(ExplorerStatus.COMPLETED) + + seer_state = state.to_seer_run_state() + + assert isinstance(seer_state, SeerRunState) + assert seer_state.run_id == state.run_id + assert seer_state.status == ExplorerStatus.COMPLETED + assert len(seer_state.blocks) == 1 + + +class TestDbStateRunTypes: + def test_explorer_type(self): + assert DbStateRunTypes.EXPLORER.value == "explorer" diff --git a/tests/seer/automation/explorer/test_tasks.py b/tests/seer/automation/explorer/test_tasks.py new file mode 100644 index 00000000..ef93ddb6 --- /dev/null +++ b/tests/seer/automation/explorer/test_tasks.py @@ -0,0 +1,139 @@ +"""Tests for Explorer Celery tasks.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from seer.automation.explorer.models import ExplorerStatus +from seer.automation.explorer.state import ExplorerRunState +from seer.automation.explorer.tasks import process_explorer_chat +from seer.db import DbRunMemory, DbRunState, Session + + +@pytest.fixture +def cleanup_explorer_runs(): + """Clean up explorer runs after tests.""" + yield + with Session() as session: + session.query(DbRunMemory).delete() + session.query(DbRunState).filter(DbRunState.type == "explorer").delete() + session.commit() + + +class TestProcessExplorerChat: + @patch("seer.automation.explorer.tasks.ExplorerAgent") + def test_process_chat_success(self, mock_agent_class, cleanup_explorer_runs): + """Test successful chat processing.""" + state = ExplorerRunState.create(organization_id=1) + + mock_agent = MagicMock() + mock_agent_class.return_value = mock_agent + + process_explorer_chat( + run_id=state.run_id, + query="What's the issue?", + ) + + # Verify agent was created and process_message was called + mock_agent_class.assert_called_once() + mock_agent.process_message.assert_called_once_with( + query="What's the issue?", + artifact_key=None, + artifact_schema=None, + tools=None, + metadata=None, + ) + + @patch("seer.automation.explorer.tasks.ExplorerAgent") + def test_process_chat_with_artifact(self, mock_agent_class, cleanup_explorer_runs): + """Test chat processing with artifact extraction.""" + state = ExplorerRunState.create(organization_id=1) + + mock_agent = MagicMock() + mock_agent_class.return_value = mock_agent + + artifact_schema = {"type": "object", "properties": {"cause": {"type": "string"}}} + + process_explorer_chat( + run_id=state.run_id, + query="Find the root cause", + artifact_key="root_cause", + artifact_schema=artifact_schema, + ) + + mock_agent.process_message.assert_called_once_with( + query="Find the root cause", + artifact_key="root_cause", + artifact_schema=artifact_schema, + tools=None, + metadata=None, + ) + + @patch("seer.automation.explorer.tasks.ExplorerAgent") + def test_process_chat_with_tools(self, mock_agent_class, cleanup_explorer_runs): + """Test chat processing with custom tools.""" + state = ExplorerRunState.create(organization_id=1) + + mock_agent = MagicMock() + mock_agent_class.return_value = mock_agent + + tools = [ + {"name": "search", "description": "Search code", "param_schema": {}}, + ] + + process_explorer_chat( + run_id=state.run_id, + query="Search for the bug", + tools=tools, + ) + + # Verify tools were converted to ToolDefinition objects + call_args = mock_agent.process_message.call_args + assert call_args.kwargs["tools"] is not None + assert len(call_args.kwargs["tools"]) == 1 + + def test_process_chat_run_not_found(self, cleanup_explorer_runs): + """Test handling of non-existent run.""" + # Should not raise, just log error + process_explorer_chat( + run_id=999999, + query="Test", + ) + + @patch("seer.automation.explorer.tasks.ExplorerAgent") + def test_process_chat_exception(self, mock_agent_class, cleanup_explorer_runs): + """Test error handling during processing.""" + state = ExplorerRunState.create(organization_id=1) + + mock_agent = MagicMock() + mock_agent.process_message.side_effect = Exception("Processing error") + mock_agent_class.return_value = mock_agent + + # Should not raise, but should set error status + process_explorer_chat( + run_id=state.run_id, + query="Test", + ) + + # Verify error state was set + run_state = state.get_state() + assert run_state.status == ExplorerStatus.ERROR + + @patch("seer.automation.explorer.tasks.ExplorerAgent") + def test_process_chat_with_metadata(self, mock_agent_class, cleanup_explorer_runs): + """Test chat processing with metadata.""" + state = ExplorerRunState.create(organization_id=1) + + mock_agent = MagicMock() + mock_agent_class.return_value = mock_agent + + metadata = {"issue": {"id": 123}, "stacktrace": "..."} + + process_explorer_chat( + run_id=state.run_id, + query="Analyze this", + metadata=metadata, + ) + + call_args = mock_agent.process_message.call_args + assert call_args.kwargs["metadata"] == metadata