diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..3326877816 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,88 @@ +version: 2 + +updates: + # Python. `uv` rather than `pip`: it resolves through the uv CLI and rewrites + # pyproject.toml alongside uv.lock, so the declared pins here — which carry + # policy, not just versions — do not drift away from the lock file. + - package-ecosystem: uv + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + python-minor-and-patch: + applies-to: version-updates + patterns: + - "*" + update-types: + - minor + - patch + python-major: + applies-to: version-updates + patterns: + - "*" + update-types: + - major + commit-message: + prefix: chore(deps) + + # GitHub Actions. Every `uses:` here is `@ # vX.Y.Z` on one line, which + # is the format Dependabot rewrites, comment included. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + actions-minor-and-patch: + applies-to: version-updates + patterns: + - "*" + update-types: + - minor + - patch + actions-major: + applies-to: version-updates + patterns: + - "*" + update-types: + - major + ignore: + # .github/workflows/pr-agent-review.yaml pins this action to a commit on + # `main`, not to a release. There is no version to bump to, and the + # documented behaviour for an untagged sha is to move it to whatever HEAD + # happens to be. Moving the self-review action is a deliberate act. + - dependency-name: Codium-ai/pr-agent + commit-message: + prefix: chore(ci) + + # Base images. The docker file fetcher matches any file whose name contains + # "dockerfile", so `/` picks up Dockerfile.github_action_dockerhub and + # `/docker` picks up both Dockerfile and Dockerfile.lambda. + - package-ecosystem: docker + directories: + - / + - /docker + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 3 + ignore: + # Dockerfile.github_action_dockerhub builds FROM this project's own + # published image, tracked by a stage tag rather than a version. + - dependency-name: pragent/pr-agent + commit-message: + prefix: chore(docker) + + # Hook revisions in .pre-commit-config.yaml. The `local` repo has no upstream + # and is left alone by the ecosystem. + - package-ecosystem: pre-commit + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 3 + commit-message: + prefix: chore(pre-commit) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 80d93c052b..124d9b353f 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -10,30 +10,64 @@ template: | categories: - title: '🚀 Features' - labels: - - feature - - enhancement + when: + labels: + - feature + - enhancement - title: '🐛 Bug Fixes' - labels: - - bug - - 'Bug fix' - - 'Bug fix with tests' + when: + labels: + - bug + - 'Bug fix' + - 'Bug fix with tests' - title: '⚡ Performance' - labels: - - performance + when: + labels: + - performance - title: '📚 Documentation' - labels: - - documentation - - 'Includes Documentation' + when: + labels: + - documentation + - 'Includes Documentation' - title: '🧹 Maintenance' - labels: - - chore - - Refactoring - - Configuration - - 'Configuration changes' + when: + labels: + - chore + - Refactoring + - Configuration + - 'Configuration changes' - title: '⬆️ Dependencies' - labels: - - dependencies + when: + labels: + - dependencies + - type: version-resolver + semver-increment: major + when: + labels: + - breaking + - type: version-resolver + semver-increment: minor + when: + labels: + - feature + - enhancement + - type: version-resolver + semver-increment: patch + when: + labels: + - bug + - 'Bug fix' + - 'Bug fix with tests' + - documentation + - 'Includes Documentation' + - chore + - Refactoring + - Configuration + - 'Configuration changes' + - performance + - dependencies + - type: version-resolver + semver-increment: patch change-template: '- $TITLE by @$AUTHOR in #$NUMBER' change-title-escapes: '\<*_&' @@ -64,26 +98,3 @@ autolabeler: - label: breaking title: - '/^[a-z]+(\(.+\))?!:/i' - -version-resolver: - major: - labels: - - breaking - minor: - labels: - - feature - - enhancement - patch: - labels: - - bug - - 'Bug fix' - - 'Bug fix with tests' - - documentation - - 'Includes Documentation' - - chore - - Refactoring - - Configuration - - 'Configuration changes' - - performance - - dependencies - default: patch diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 16f76f89ed..5c1596ded9 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -14,15 +14,24 @@ jobs: steps: - id: checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - id: uv + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + + - name: Check uv.lock + run: uv lock --check + + - name: Test package artifacts + run: uv run pytest -q tests/packaging/test_help_docs_package.py - id: dockerx name: Setup Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - id: build name: Build dev docker - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ./docker/Dockerfile @@ -47,7 +56,7 @@ jobs: exit 1 fi - name: Upload coverage to Codecov - uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -55,7 +64,7 @@ jobs: # Smoke-start github_app (gunicorn + uvicorn) with no secrets; GET / → 200. - id: build-smoke name: Build github_app docker - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ./docker/Dockerfile @@ -82,3 +91,39 @@ jobs: docker logs pr-agent-smoke docker rm -f pr-agent-smoke exit 1 + + # The job above runs entirely inside docker/Dockerfile, whose base moved to + # python:3.14.7-slim in #3294. That leaves neither interpreter pyproject + # actually claims support for exercised there: not the `requires-python` + # floor of 3.12, and not the 3.13 the google-cloud-storage pin applies at. + # Installing from PyPI on either was an untested combination. Deliberately + # separate from the docker build rather than a matrix over it: the shipped + # images track one interpreter on purpose, and matrixing would rebuild every + # layer and re-run the smoke test and coverage upload for no extra signal. + unit-tests-native: + name: Unit tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13", "3.14"] + + steps: + - id: checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Same uv release the Dockerfile pulls from ghcr.io/astral-sh/uv, via pipx + # so there is no additional action to pin. uv provisions the interpreter + # itself, so setup-python is not needed either. + - id: uv + name: Install uv + run: pipx install uv==0.12.10 + + - id: test + name: Test on Python ${{ matrix.python-version }} + env: + PYTHONPATH: . + # See docker/Dockerfile's base stage: pin litellm's bundled cost map so + # a remote change to it cannot redden the suite without a version bump. + LITELLM_LOCAL_MODEL_COST_MAP: "True" + run: uv run --frozen --python ${{ matrix.python-version }} pytest -v tests/unittest diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 303d59d7da..ce13ce7e54 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,14 +31,14 @@ jobs: - language: python build-mode: none steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: github/codeql-action/init@7fd177fa680c9881b53cdab4d346d32574c9f7f4 # v3.35.4 + - uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} queries: security-extended,security-and-quality - - uses: github/codeql-action/analyze@7fd177fa680c9881b53cdab4d346d32574c9f7f4 # v3.35.4 + - uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/docs-ci.yaml b/.github/workflows/docs-ci.yaml index fb1056c4eb..946daac080 100644 --- a/.github/workflows/docs-ci.yaml +++ b/.github/workflows/docs-ci.yaml @@ -12,16 +12,16 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Configure Git Credentials run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: 3.x - run: echo "cache_id=$(date --utc '+%V')" >> "$GITHUB_ENV" - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: mkdocs-material-${{ env.cache_id }} path: .cache diff --git a/.github/workflows/e2e_tests.yaml b/.github/workflows/e2e_tests.yaml index 0d33afb903..22f292c9ac 100644 --- a/.github/workflows/e2e_tests.yaml +++ b/.github/workflows/e2e_tests.yaml @@ -11,14 +11,14 @@ jobs: name: PR-Agent E2E GitHub App Test steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - id: build name: Build dev docker - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ./docker/Dockerfile diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 24aa2de365..72a3dd19f4 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -1,9 +1,10 @@ -# disabled. We might run it manually if needed. +# Runs every pre-commit hook (ruff, actionlint, YAML/TOML checks, ...) on +# pull requests, so the hooks are enforced in CI rather than manual-only. name: pre-commit on: workflow_dispatch: -# pull_request: + pull_request: # push: # branches: [main] @@ -11,7 +12,8 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 # SEE https://github.com/pre-commit/action - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 diff --git a/.github/workflows/provider-smoke.yaml b/.github/workflows/provider-smoke.yaml new file mode 100644 index 0000000000..862742d8cd --- /dev/null +++ b/.github/workflows/provider-smoke.yaml @@ -0,0 +1,32 @@ +name: Provider smoke + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + pull-requests: read + issues: read + +jobs: + github-provider-smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup uv + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + + - name: Install dependencies + run: uv sync + + - name: Smoke /describe on a real GitHub PR + env: + GITHUB.USER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: PYTHONPATH=. uv run pytest tests/health_test/test_github_smoke.py -q diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 74990eb7a2..142b13f0fe 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -83,11 +83,11 @@ jobs: runs-on: ubuntu-latest environment: release steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.prepare.outputs.sha }} - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' @@ -99,7 +99,7 @@ jobs: python -m pip install --upgrade pip build python -m build - - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: password: ${{ secrets.PYPI_API_TOKEN }} # Re-running the workflow for the same version (e.g. after a @@ -168,25 +168,25 @@ jobs: suffix: gitlab_lambda rolling: gitlab_lambda steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.prepare.outputs.sha }} - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' - name: Set version in pyproject.toml run: python scripts/set_pyproject_version.py "${{ needs.prepare.outputs.version }}" - - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 with: # amd64 is the runner's native arch; only arm64 needs emulation. platforms: arm64 - - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -208,7 +208,7 @@ jobs: echo "tags=$TAGS" >> "$GITHUB_OUTPUT" - id: push - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ${{ matrix.dockerfile }} @@ -219,7 +219,7 @@ jobs: cache-from: type=gha,scope=${{ matrix.suffix || 'cli' }} cache-to: type=gha,mode=max,scope=${{ matrix.suffix || 'cli' }} - - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-name: index.docker.io/pragent/pr-agent subject-digest: ${{ steps.push.outputs.digest }} @@ -245,16 +245,18 @@ jobs: # the push rather than rebasing onto a newer commit that wasn't built. # This preserves the invariant that `git checkout vX.Y.Z` resolves to a # commit whose tree was published. - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.prepare.outputs.sha }} fetch-depth: 0 token: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + - name: Flag missing PyPI publish if: needs.publish-pypi.result != 'success' env: @@ -267,8 +269,10 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Bump pyproject.toml - run: python scripts/set_pyproject_version.py "${{ needs.prepare.outputs.version }}" + - name: Bump pyproject.toml and sync uv.lock + run: | + python scripts/set_pyproject_version.py "${{ needs.prepare.outputs.version }}" + uv lock - name: Commit & push bump (fast-forward only) id: commit @@ -276,19 +280,19 @@ jobs: VERSION: ${{ needs.prepare.outputs.version }} PUBLISHED_SHA: ${{ needs.prepare.outputs.sha }} run: | - if git diff --quiet pyproject.toml; then - echo "pyproject.toml already at $VERSION on $PUBLISHED_SHA; nothing to commit." + if git diff --quiet pyproject.toml uv.lock; then + echo "pyproject.toml and uv.lock already at $VERSION on $PUBLISHED_SHA; nothing to commit." echo "bumped=false" >> "$GITHUB_OUTPUT" echo "sha=$PUBLISHED_SHA" >> "$GITHUB_OUTPUT" exit 0 fi - git add pyproject.toml + git add pyproject.toml uv.lock git commit -m "chore(release): bump version to $VERSION [skip ci]" if git push origin "HEAD:refs/heads/main"; then echo "bumped=true" >> "$GITHUB_OUTPUT" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" else - echo "::warning::main has moved past the published commit ($PUBLISHED_SHA); skipping the bump push rather than rebasing onto a commit that was not built. Bump pyproject.toml on main manually: git checkout main && python scripts/set_pyproject_version.py $VERSION && git commit -am 'chore(release): bump version to $VERSION' && git push" + echo "::warning::main has moved past the published commit ($PUBLISHED_SHA); skipping the bump push rather than rebasing onto a commit that was not built. Bump pyproject.toml and uv.lock on main manually: git checkout main && python scripts/set_pyproject_version.py $VERSION && uv lock && git commit -am 'chore(release): bump version to $VERSION' && git push" echo "bumped=false" >> "$GITHUB_OUTPUT" echo "sha=$PUBLISHED_SHA" >> "$GITHUB_OUTPUT" fi diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index b3cc0b2fcf..42715be406 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -15,11 +15,23 @@ permissions: jobs: update_release_draft: + if: github.event_name == 'push' permissions: contents: write + pull-requests: read + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + auto_label: + if: github.event_name == 'pull_request_target' + permissions: + contents: read pull-requests: write runs-on: ubuntu-latest steps: - - uses: release-drafter/release-drafter@67e173cadb2fbd3de94f4a861e0c48c913b462ae # v6.4.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - uses: release-drafter/release-drafter/autolabeler@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 040f445e1a..3169689682 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ dist/ build/ .DS_Store docs/.cache/ +docs/site/ .qodo poetry.lock .log diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d6f90b468..47f9c6ff34 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ default_language_version: repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - id: check-added-large-files exclude: ^uv\.lock$ # lock file, 686 KB and generated @@ -28,7 +28,7 @@ repos: exclude: ^tests/unittest/test_extend_patch\.py$ # trailing space; stripping it breaks the test. - repo: https://github.com/rhysd/actionlint - rev: v1.7.12 + rev: 914e7df21a07ef503a81201c76d2b11c789d3fca # frozen: v1.7.12 hooks: - id: actionlint @@ -41,11 +41,3 @@ repos: language: system types_or: [python, pyi] args: [--fix, --show-fixes] - - # - repo: https://github.com/PyCQA/bandit - # rev: 1.7.10 - # hooks: - # - id: bandit - # args: [ - # "-c", "pyproject.toml", - # ] diff --git a/AGENTS.md b/AGENTS.md index 02fab8b2b8..6139521989 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ Ruff is the single linting tool: `pyproject.toml` configures it and the pre-comm - Keep Python lines within the 120-character limit declared in `pyproject.toml`. - `pyproject.toml` configures Ruff rules `E`, `F`, `B` and `I`; `I001` (import sorting), `F401` (unused imports), and `F541` (f-strings without placeholders) are fixable. The `lint.ignore` list defers pre-existing violations — treat it as a debt ledger: fix the code and drop entries rather than adding new ones. - Before committing, run `uv run ruff check --fix` on the files you touched and fix every issue it reports. Keep fixes mechanical (rename, remove unused imports, sort imports); do not alter program logic while cleaning up — if a lint fix would change behavior, surface it instead of applying it silently. -- `.pre-commit-config.yaml` is the source of truth for enabled pre-commit hooks. Run them on the files you touched with `uv run pre-commit run --files ` and review the automatic edits so unrelated changes are not included. The pre-commit GitHub Actions workflow is manual-only (`workflow_dispatch`); the hooks are not enforced in CI. +- `.pre-commit-config.yaml` is the source of truth for enabled pre-commit hooks. Run them on the files you touched with `uv run pre-commit run --files ` and review the automatic edits so unrelated changes are not included. The pre-commit GitHub Actions workflow runs on pull requests, so the hooks are enforced in CI. - No general-purpose Python formatter is currently enforced (`ruff format` is deliberately not adopted yet). Preserve the surrounding file's formatting and avoid unrelated rewrites or repository-wide formatting. - Prefer double quotes for Python strings where consistent with the surrounding file. - Match existing docstring and comment style—concise English comments using imperative phrasing only where necessary. @@ -97,7 +97,7 @@ Ruff is the single linting tool: `pyproject.toml` configures it and the pre-comm - Follow `CONTRIBUTING.md`: keep changes focused, add or update tests, and use Conventional Commit-style messages (e.g., `fix: handle missing repo settings gracefully`). - Target branch names follow `feature/` or `fix/` patterns for substantial work. - Reference related issues and update README or docs when user-facing behavior shifts. -- Ensure CI workflows (`build-and-test`, `code-coverage`, `docs-ci`) succeed locally or in draft PRs before requesting review; reproduce failures with the documented commands above. +- Before requesting review, run the relevant local checks above and make sure the corresponding CI workflows (`build-and-test`, `pre-commit`) pass; coverage is collected and uploaded by `build-and-test`, and `docs-ci` runs only on pushes to `main` and `add-docs-portal`. - Include screenshots or terminal captures when modifying user-visible output or documentation previews. ## Safety and Permissions diff --git a/MANIFEST.in b/MANIFEST.in index 194b3def92..bda73ce4a4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ recursive-include pr_agent *.toml +recursive-include docs/docs *.md recursive-exclude pr_agent *.secrets.toml diff --git a/README.md b/README.md index 93b81f73df..b6b778d1ba 100644 --- a/README.md +++ b/README.md @@ -106,25 +106,6 @@ pr-agent --pr_url https://github.com/owner/repo/pull/123 review Full notes for every release are on the [Releases page](https://github.com/the-pr-agent/pr-agent/releases). -### Jul 26, 2026 — [v0.41.0](https://github.com/the-pr-agent/pr-agent/releases/tag/v0.41.0) - -Claude Opus 5 support, and `docker/mosaico` became a self-contained deployment bundle. - -### Jul 25, 2026 — [v0.40.0](https://github.com/the-pr-agent/pr-agent/releases/tag/v0.40.0) - -Default model moved to GPT-5.6, Gemini 3.6 support, persistent inline comments (no more duplicate -suggestions across runs), an OpenRouter provider-routing/reasoning config, a tokenless -[plain-diff provider](https://docs.pr-agent.ai/usage-guide/plain_diff_mode/), and CI artifact -context injection. - -### Jul 5, 2026 — [v0.39.0](https://github.com/the-pr-agent/pr-agent/releases/tag/v0.39.0) - -`AGENTS.md` and friends are now fed to `/review`, `/describe` and `/improve` **by default**, so the -model picks up your project's conventions out of the box. Also: -[Agent Skills (`SKILL.md`)](https://docs.pr-agent.ai/core-abilities/agent_skills/), -[organization-level settings](https://docs.pr-agent.ai/usage-guide/configuration_options/), -[restricted mode](https://docs.pr-agent.ai/usage-guide/additional_configurations/#restricted-mode) -for reduced GitHub permissions, GitHub Checks as an output target, and Claude Sonnet 5 support. ## Why Use PR-Agent? @@ -201,8 +182,12 @@ PR-Agent tools run as a comment on a PR or from the CLI. A few common ones: /improve /ask "What does this PR change?" # free-text Q&A about the PR +# Issue-scoped commands run on an issue instead of a PR: +/similar_issue # find similar issues in the repository + # Or locally via the CLI: pr-agent --pr_url review +pr-agent --issue_url similar_issue ``` See the [Tools docs](https://docs.pr-agent.ai/tools/#usage-examples) for the full list of tools with example commands, and each tool's page for screenshots and options. diff --git a/docker/Dockerfile b/docker/Dockerfile index 11467cf521..ac830e5f27 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,64 +1,79 @@ -FROM python:3.12.14-slim AS base +FROM python:3.14.7-slim AS base COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /uvx /bin/ -RUN apt update && apt install --no-install-recommends -y git curl && apt-get clean && rm -rf /var/lib/apt/lists/* +# litellm fetches its model cost map over the network at import time, so a +# third-party change to that map can change runtime behavior with no image +# change and no version bump. Use the map bundled with the litellm pinned in +# uv.lock so every stage (test and runtime) behaves how it was built and tested +# rather than tracking today's remote data; new models then arrive with a +# deliberate litellm bump in the lockfile. +ENV LITELLM_LOCAL_MODEL_COST_MAP=True + +RUN apt-get update && apt-get install --no-install-recommends -y git curl && apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR /app ENV UV_COMPILE_BYTECODE=1 -ADD pyproject.toml uv.lock ./ -ADD docs docs +COPY pyproject.toml uv.lock ./ +COPY docs docs RUN uv sync --frozen --no-install-project --no-dev ENV PATH="/app/.venv/bin:$PATH" ENV PYTHONPATH=/app FROM base AS github_app -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "-m", "gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-c", "pr_agent/servers/gunicorn_config.py", "--forwarded-allow-ips", "*", "pr_agent.servers.github_app:app"] FROM base AS bitbucket_app -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "pr_agent/servers/bitbucket_app.py"] FROM base AS bitbucket_server_webhook -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "pr_agent/servers/bitbucket_server_webhook.py"] FROM base AS github_polling -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "pr_agent/servers/github_polling.py"] FROM base AS github_action -ADD pr_agent pr_agent -ADD github_action/entrypoint.sh / +COPY pr_agent pr_agent +COPY github_action/entrypoint.sh / RUN chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] FROM base AS gitlab_webhook -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "-m", "gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-c", "pr_agent/servers/gunicorn_config.py", "--forwarded-allow-ips", "*", "pr_agent.servers.gitlab_webhook:app"] FROM base AS azure_devops_webhook -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "pr_agent/servers/azuredevops_server_webhook.py"] FROM base AS gitea_app -ADD pr_agent pr_agent +COPY pr_agent pr_agent CMD ["python", "-m", "gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-c", "pr_agent/servers/gunicorn_config.py","pr_agent.servers.gitea_app:app"] FROM base AS mosaico_agent -ADD pr_agent pr_agent +COPY pr_agent pr_agent EXPOSE 9000 CMD ["python", "pr_agent/mosaico/server.py"] FROM base AS test +# The base stage pins LITELLM_LOCAL_MODEL_COST_MAP for every stage; kept here +# only as documentation that the unit suite keys off the bundled cost map. RUN uv sync --frozen --no-install-project -ADD pr_agent pr_agent -ADD tests tests -ADD .github/workflows/publish.yml .github/workflows/publish.yml -ADD docker/mosaico docker/mosaico +COPY pr_agent pr_agent +COPY tests tests +# Config the unit suite asserts against, copied file by file so an unrelated +# workflow edit does not invalidate this layer. +COPY .github/workflows/publish.yml .github/workflows/publish.yml +COPY .github/workflows/build-and-test.yaml .github/workflows/build-and-test.yaml +COPY docker/Dockerfile docker/Dockerfile +COPY docker/Dockerfile.lambda docker/Dockerfile.lambda +COPY docker/mosaico docker/mosaico FROM base AS cli -ADD pr_agent pr_agent +COPY pr_agent pr_agent RUN uv sync --frozen --no-dev && ln -s /app/.venv/bin/pr-agent /usr/local/bin/pr-agent ENTRYPOINT ["python", "pr_agent/cli.py"] diff --git a/docker/Dockerfile.lambda b/docker/Dockerfile.lambda index bda5ee05ac..9f7705dd21 100644 --- a/docker/Dockerfile.lambda +++ b/docker/Dockerfile.lambda @@ -1,6 +1,14 @@ -FROM public.ecr.aws/lambda/python:3.12 AS base +# Keep :3.14 floating: AWS publishes no CPython patch tags for Lambda +# bases, only the minor tag and per-build dated tags that rotate almost +# daily. Chasing dated tags adds bump churn for no real benefit; minor +# alignment with docker/Dockerfile is enforced by the CI version guard. +FROM public.ecr.aws/lambda/python:3.14 AS base COPY --from=ghcr.io/astral-sh/uv:0.12.10 /uv /uvx /bin/ +# Use the cost map bundled with the litellm pinned in uv.lock so lambda images +# behave as built rather than fetching today's remote data at import time. +ENV LITELLM_LOCAL_MODEL_COST_MAP=True + RUN dnf update -y && \ dnf install -y gcc python3-devel git && \ dnf clean all @@ -8,10 +16,19 @@ RUN dnf update -y && \ # Build the locked environment at /opt/venv with uv; put its site-packages # on the Lambda runtime's import path alongside the task root via PYTHONPATH. ENV UV_COMPILE_BYTECODE=1 UV_PROJECT_ENVIRONMENT=/opt/venv -ADD pyproject.toml uv.lock ./ -RUN uv sync --frozen --no-install-project --no-dev --extra lambda --python /var/lang/bin/python3.12 -ENV PYTHONPATH="/opt/venv/lib/python3.12/site-packages:${LAMBDA_TASK_ROOT}" +WORKDIR ${LAMBDA_TASK_ROOT} +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-install-project --no-dev --extra lambda --python /var/lang/bin/python3.14 +ENV PYTHONPATH="/opt/venv/lib/python3.14/site-packages:${LAMBDA_TASK_ROOT}" COPY pr_agent/ ${LAMBDA_TASK_ROOT}/pr_agent/ +RUN --mount=type=bind,source=docs/docs,target=/tmp/help-docs-source \ + source_count="$(find /tmp/help-docs-source -type f -name '*.md' | wc -l)" && \ + test "$source_count" -gt 0 && \ + cd /tmp/help-docs-source && \ + find . -type f -name '*.md' -exec sh -c 'for source in "$@"; do destination="${LAMBDA_TASK_ROOT}/pr_agent/_help_docs/${source#./}"; mkdir -p "$(dirname "$destination")"; cp "$source" "$destination"; done' sh {} + && \ + destination_count="$(find "${LAMBDA_TASK_ROOT}/pr_agent/_help_docs" -type f | wc -l)" && \ + test "$destination_count" -eq "$source_count" && \ + test -z "$(find "${LAMBDA_TASK_ROOT}/pr_agent/_help_docs" -type f ! -name '*.md' -print -quit)" FROM base AS github_lambda CMD ["pr_agent.servers.github_lambda_webhook.lambda_handler"] diff --git a/docker/mosaico/README.md b/docker/mosaico/README.md index 99f7be1884..45a677136b 100644 --- a/docker/mosaico/README.md +++ b/docker/mosaico/README.md @@ -4,13 +4,13 @@ Deployment assets for running **PR-Agent** as a [MOSAICO](https://mosaico-projec *solution agent*. This directory contains no Python and no pr-agent source — it consumes PR-Agent as a published, version-pinned Docker image. The agent's source lives in [`The-PR-Agent/pr-agent`](https://github.com/The-PR-Agent/pr-agent), under `pr_agent/mosaico/`; -it is merged into `main` and ships in every release wheel and image starting at `v0.37.0`. +it is merged into `main` and ships in every release wheel and image starting at `v0.36.0`. ## Relationship to the PR-Agent repository This is not a fork and it never becomes one: -- The MOSAICO A2A server is PR-Agent code, released in tags `v0.37.0` onwards. This bundle +- The MOSAICO A2A server is PR-Agent code, released in tags `v0.36.0` onwards. This bundle holds zero Python — only a compose overlay, a registration template, an env template, a smoke test, and this README. - **Staying current is one line**: bump the pinned tag in `docker-compose.pr-agent.yml`, then @@ -132,9 +132,21 @@ Two outcomes: ## Troubleshooting - **Container stays `unhealthy`, registration never runs.** `/health` is a live LLM probe and - returns `503` on bad/missing credentials — this is intended (the healthcheck matches the - peer solution agents' probe verbatim, and a registered card backed by a dead LLM is worse - than no registration). Check `API_BASE`/`API_KEY`/`MODEL_NAME`, not the compose file. + returns `503` on bad/missing credentials — this is intended: a registered card backed by + a dead LLM is worse than no registration. Images with health-probe hardening return + `Unhealthy: LLM probe failed` for provider failures. The health check's own warning logs only the exception type, + not the provider's error text. + `mosaico.health_timeout_seconds` sets the deadline for cooperative asynchronous work + (default: 10 seconds; must be a finite positive number). Synchronous + handler construction and blocking SDK work can still exceed it. Existing image tags may + predate this behavior and expose provider error details. + Set `MOSAICO__HEALTH_TIMEOUT_SECONDS` in the agent container's environment to override + the default; with Compose, add it to the service's `environment` mapping. + Invalid values make `/health` return the generic unhealthy response (503), not the default timeout. + The bundled Compose probe has its own 25-second HTTP timeout. For longer health budgets, + increase that HTTP timeout and Docker's `healthcheck.timeout` with sufficient margin; + otherwise the container can remain unhealthy and registration will not run. + Check `API_BASE`/`API_KEY`/`MODEL_NAME`, not the compose file. - **Agent registers but the reference agent never reaches it.** The advertised card URL is `localhost`; see the `AGENT_CARD_HOST`/`AGENT_CARD_PORT` section above. - **The registration container itself can't fetch the agent card.** `01-compose.sh` falls back diff --git a/docker/mosaico/smoke_test.sh b/docker/mosaico/smoke_test.sh index fa1f49b667..1aa1c88f85 100755 --- a/docker/mosaico/smoke_test.sh +++ b/docker/mosaico/smoke_test.sh @@ -23,8 +23,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/.env}" BASE="http://localhost:${PORT}" -# 0700 by construction, so the /health body (which embeds the raw provider exception -# when unhealthy) is neither world-readable nor writable at a predictable path. +# 0700 by construction, so captured responses are neither world-readable nor +# writable at a predictable path, including when testing older image versions. TMPDIR_RUN="$(mktemp -d)" || { echo "FAIL: mktemp -d failed" >&2; exit 1; } # Only tear down a container this run actually started: the name is fixed, so an early # exit (a failed pull, or a `docker run` that lost a name race) must not reap someone @@ -106,8 +106,8 @@ fi # --- FULL: /health (live LLM ping -> 200/503) --- echo "==> [full] GET /health (live LLM probe)" code=$(curl -s "${CURL_CONNECT[@]}" --max-time 120 -o "$TMPDIR_RUN/health.json" -w '%{http_code}' "$BASE/health") -# On 503 the body is a raw provider exception (it can name the endpoint), which is -# exactly the diagnostic you want here - just don't paste it into a public issue. +# Images without health-probe redaction can include provider details in a 503 body; +# inspect captured output before sharing it publicly. cat "$TMPDIR_RUN/health.json"; echo [[ "$code" == "200" ]] || fail "/health returned $code (expected 200) — check LLM creds" diff --git a/docs/docs/assets/Ask_line.png b/docs/docs/assets/Ask_line.png new file mode 100644 index 0000000000..848132c349 Binary files /dev/null and b/docs/docs/assets/Ask_line.png differ diff --git a/docs/docs/assets/add_docs_comment.png b/docs/docs/assets/add_docs_comment.png new file mode 100644 index 0000000000..5ff47a8cbb Binary files /dev/null and b/docs/docs/assets/add_docs_comment.png differ diff --git a/docs/docs/assets/add_native_custom_labels.webp b/docs/docs/assets/add_native_custom_labels.webp new file mode 100644 index 0000000000..79b25f3500 Binary files /dev/null and b/docs/docs/assets/add_native_custom_labels.webp differ diff --git a/docs/docs/assets/ask.png b/docs/docs/assets/ask.png new file mode 100644 index 0000000000..e37705e189 Binary files /dev/null and b/docs/docs/assets/ask.png differ diff --git a/docs/docs/assets/ask_comment.png b/docs/docs/assets/ask_comment.png new file mode 100644 index 0000000000..cdb5d5645b Binary files /dev/null and b/docs/docs/assets/ask_comment.png differ diff --git a/docs/docs/assets/ask_images1.png b/docs/docs/assets/ask_images1.png new file mode 100644 index 0000000000..06f401ccf1 Binary files /dev/null and b/docs/docs/assets/ask_images1.png differ diff --git a/docs/docs/assets/ask_images2.png b/docs/docs/assets/ask_images2.png new file mode 100644 index 0000000000..15dae76472 Binary files /dev/null and b/docs/docs/assets/ask_images2.png differ diff --git a/docs/docs/assets/ask_images3.png b/docs/docs/assets/ask_images3.png new file mode 100644 index 0000000000..d788afe3a3 Binary files /dev/null and b/docs/docs/assets/ask_images3.png differ diff --git a/docs/docs/assets/ask_images4.png b/docs/docs/assets/ask_images4.png new file mode 100644 index 0000000000..63cdc96b38 Binary files /dev/null and b/docs/docs/assets/ask_images4.png differ diff --git a/docs/docs/assets/ask_images5.png b/docs/docs/assets/ask_images5.png new file mode 100644 index 0000000000..0ea9fb7a24 Binary files /dev/null and b/docs/docs/assets/ask_images5.png differ diff --git a/docs/docs/assets/azure_devops_pipeline_secrets.png b/docs/docs/assets/azure_devops_pipeline_secrets.png new file mode 100644 index 0000000000..aa838b3e41 Binary files /dev/null and b/docs/docs/assets/azure_devops_pipeline_secrets.png differ diff --git a/docs/docs/assets/code_suggestions_as_comment_closed.png b/docs/docs/assets/code_suggestions_as_comment_closed.png new file mode 100644 index 0000000000..74d928f9eb Binary files /dev/null and b/docs/docs/assets/code_suggestions_as_comment_closed.png differ diff --git a/docs/docs/assets/code_suggestions_as_comment_open.png b/docs/docs/assets/code_suggestions_as_comment_open.png new file mode 100644 index 0000000000..08bad76a8b Binary files /dev/null and b/docs/docs/assets/code_suggestions_as_comment_open.png differ diff --git a/docs/docs/assets/describe_comment.png b/docs/docs/assets/describe_comment.png new file mode 100644 index 0000000000..5b13930ddb Binary files /dev/null and b/docs/docs/assets/describe_comment.png differ diff --git a/docs/docs/assets/describe_markers_after.webp b/docs/docs/assets/describe_markers_after.webp new file mode 100644 index 0000000000..fe021244a5 Binary files /dev/null and b/docs/docs/assets/describe_markers_after.webp differ diff --git a/docs/docs/assets/describe_markers_before.png b/docs/docs/assets/describe_markers_before.png new file mode 100644 index 0000000000..939099c80d Binary files /dev/null and b/docs/docs/assets/describe_markers_before.png differ diff --git a/docs/docs/assets/describe_new.webp b/docs/docs/assets/describe_new.webp new file mode 100644 index 0000000000..8fc0b222d5 Binary files /dev/null and b/docs/docs/assets/describe_new.webp differ diff --git a/docs/docs/assets/describe_new_short_main.webp b/docs/docs/assets/describe_new_short_main.webp new file mode 100644 index 0000000000..26cfca0f2e Binary files /dev/null and b/docs/docs/assets/describe_new_short_main.webp differ diff --git a/docs/docs/assets/diagram-v0.9.webp b/docs/docs/assets/diagram-v0.9.webp new file mode 100644 index 0000000000..1c0694cf3c Binary files /dev/null and b/docs/docs/assets/diagram-v0.9.webp differ diff --git a/docs/docs/assets/filter_mail_notifications.png b/docs/docs/assets/filter_mail_notifications.png new file mode 100644 index 0000000000..329b8efa8f Binary files /dev/null and b/docs/docs/assets/filter_mail_notifications.png differ diff --git a/docs/docs/assets/git_patch_logic.png b/docs/docs/assets/git_patch_logic.png new file mode 100644 index 0000000000..b88c382ab0 Binary files /dev/null and b/docs/docs/assets/git_patch_logic.png differ diff --git a/docs/docs/assets/help1.png b/docs/docs/assets/help1.png new file mode 100644 index 0000000000..04ffae16c4 Binary files /dev/null and b/docs/docs/assets/help1.png differ diff --git a/docs/docs/assets/help2.webp b/docs/docs/assets/help2.webp new file mode 100644 index 0000000000..9519e72d09 Binary files /dev/null and b/docs/docs/assets/help2.webp differ diff --git a/docs/docs/assets/help_docs_comment.png b/docs/docs/assets/help_docs_comment.png new file mode 100644 index 0000000000..a63014a438 Binary files /dev/null and b/docs/docs/assets/help_docs_comment.png differ diff --git a/docs/docs/assets/help_docs_comment_explicit_git.png b/docs/docs/assets/help_docs_comment_explicit_git.png new file mode 100644 index 0000000000..f1ef78dd12 Binary files /dev/null and b/docs/docs/assets/help_docs_comment_explicit_git.png differ diff --git a/docs/docs/assets/help_docs_response.png b/docs/docs/assets/help_docs_response.png new file mode 100644 index 0000000000..9200d3f4d2 Binary files /dev/null and b/docs/docs/assets/help_docs_response.png differ diff --git a/docs/docs/assets/improve.png b/docs/docs/assets/improve.png new file mode 100644 index 0000000000..04b413dcd0 Binary files /dev/null and b/docs/docs/assets/improve.png differ diff --git a/docs/docs/assets/improve_new_short_main.webp b/docs/docs/assets/improve_new_short_main.webp new file mode 100644 index 0000000000..b6e5a82161 Binary files /dev/null and b/docs/docs/assets/improve_new_short_main.webp differ diff --git a/docs/docs/assets/notifications.png b/docs/docs/assets/notifications.png new file mode 100644 index 0000000000..f8ce3089f6 Binary files /dev/null and b/docs/docs/assets/notifications.png differ diff --git a/docs/docs/assets/possible_config1.png b/docs/docs/assets/possible_config1.png new file mode 100644 index 0000000000..e09246055d Binary files /dev/null and b/docs/docs/assets/possible_config1.png differ diff --git a/docs/docs/assets/possible_config2.png b/docs/docs/assets/possible_config2.png new file mode 100644 index 0000000000..9d123283b7 Binary files /dev/null and b/docs/docs/assets/possible_config2.png differ diff --git a/docs/docs/assets/pr_description_user_description.png b/docs/docs/assets/pr_description_user_description.png new file mode 100644 index 0000000000..8665fc3a82 Binary files /dev/null and b/docs/docs/assets/pr_description_user_description.png differ diff --git a/docs/docs/assets/review3.png b/docs/docs/assets/review3.png new file mode 100644 index 0000000000..3fd9e89894 Binary files /dev/null and b/docs/docs/assets/review3.png differ diff --git a/docs/docs/assets/review_comment.png b/docs/docs/assets/review_comment.png new file mode 100644 index 0000000000..17dc535f00 Binary files /dev/null and b/docs/docs/assets/review_comment.png differ diff --git a/docs/docs/assets/review_new_short_main.png b/docs/docs/assets/review_new_short_main.png new file mode 100644 index 0000000000..13c86c915a Binary files /dev/null and b/docs/docs/assets/review_new_short_main.png differ diff --git a/docs/docs/assets/self_reflection1.png b/docs/docs/assets/self_reflection1.png new file mode 100644 index 0000000000..56c8bfda2a Binary files /dev/null and b/docs/docs/assets/self_reflection1.png differ diff --git a/docs/docs/assets/self_reflection2.png b/docs/docs/assets/self_reflection2.png new file mode 100644 index 0000000000..e9bbb219eb Binary files /dev/null and b/docs/docs/assets/self_reflection2.png differ diff --git a/docs/docs/assets/self_review_1.webp b/docs/docs/assets/self_review_1.webp new file mode 100644 index 0000000000..c7404164c4 Binary files /dev/null and b/docs/docs/assets/self_review_1.webp differ diff --git a/docs/docs/assets/similar_issue.png b/docs/docs/assets/similar_issue.png new file mode 100644 index 0000000000..5f5d995c08 Binary files /dev/null and b/docs/docs/assets/similar_issue.png differ diff --git a/docs/docs/assets/similar_issue_comment.png b/docs/docs/assets/similar_issue_comment.png new file mode 100644 index 0000000000..0e8d2efc8f Binary files /dev/null and b/docs/docs/assets/similar_issue_comment.png differ diff --git a/docs/docs/assets/similar_issue_original_issue.png b/docs/docs/assets/similar_issue_original_issue.png new file mode 100644 index 0000000000..a09e703aab Binary files /dev/null and b/docs/docs/assets/similar_issue_original_issue.png differ diff --git a/docs/docs/assets/ticket_compliance_review.png b/docs/docs/assets/ticket_compliance_review.png new file mode 100644 index 0000000000..46e883ea79 Binary files /dev/null and b/docs/docs/assets/ticket_compliance_review.png differ diff --git a/docs/docs/assets/update_changelog.png b/docs/docs/assets/update_changelog.png new file mode 100644 index 0000000000..eb2472a4cc Binary files /dev/null and b/docs/docs/assets/update_changelog.png differ diff --git a/docs/docs/assets/update_changelog_comment.png b/docs/docs/assets/update_changelog_comment.png new file mode 100644 index 0000000000..deeb8c27aa Binary files /dev/null and b/docs/docs/assets/update_changelog_comment.png differ diff --git a/docs/docs/core-abilities/compression_strategy.md b/docs/docs/core-abilities/compression_strategy.md index a4448f0e0b..60a65bcb1f 100644 --- a/docs/docs/core-abilities/compression_strategy.md +++ b/docs/docs/core-abilities/compression_strategy.md @@ -1,5 +1,5 @@ -`Supported Git Platforms: GitHub, GitLab, Bitbucket` +`Supported Git Platforms: GitHub, GitLab, Bitbucket, Azure DevOps, Gitea` ## Overview @@ -54,4 +54,4 @@ We use [tiktoken](https://github.com/openai/tiktoken) to tokenize the patches af #### Example -![Core Abilities](https://codium.ai/images/git_patch_logic.png){width=768} +![Core Abilities](../assets/git_patch_logic.png){width=768} diff --git a/docs/docs/core-abilities/dynamic_context.md b/docs/docs/core-abilities/dynamic_context.md index 8e862fe113..f53ac6d323 100644 --- a/docs/docs/core-abilities/dynamic_context.md +++ b/docs/docs/core-abilities/dynamic_context.md @@ -1,5 +1,5 @@ -`Supported Git Platforms: GitHub, GitLab, Bitbucket` +`Supported Git Platforms: GitHub, GitLab, Bitbucket, Azure DevOps, Gitea` PR-Agent uses an **asymmetric and dynamic context strategy** to improve AI analysis of code changes in pull requests. It provides more context before changes than after, and dynamically adjusts the context based on code structure (e.g., enclosing functions or classes). diff --git a/docs/docs/core-abilities/fetching_ticket_context.md b/docs/docs/core-abilities/fetching_ticket_context.md index 7ef9cf910c..0d12f65f03 100644 --- a/docs/docs/core-abilities/fetching_ticket_context.md +++ b/docs/docs/core-abilities/fetching_ticket_context.md @@ -1,9 +1,10 @@ # Fetching Ticket Context for PRs -`Supported Git Platforms: GitHub, GitLab, Bitbucket` +`Supported Git Platforms: GitHub, GitLab, Bitbucket, Azure DevOps, Gitea` -!!! note "Branch-name issue linking: GitHub only (for now)" - Extracting issue links from the **branch name** (and the optional `branch_issue_regex` setting) is currently implemented for **GitHub only**. Support for GitLab, Bitbucket, and other platforms is planned for a later release. The GitHub flow was the most relevant to implement first; other providers will follow. +!!! note "Branch-name linking: Jira keys on all providers; numeric GitHub issues on GitHub only" + **Jira** ticket keys (e.g. `ABC-123`) are extracted from the branch name on **every git provider**. + Extracting **numeric GitHub issue** links from the branch name (and the optional `branch_issue_regex` setting) is currently implemented for **GitHub only**; support for other providers is planned for a later release. ## Overview @@ -50,7 +51,7 @@ Each ticket will be assigned a label (Compliance/Alignment level), Indicates the - Not Compliant - PR Code Verified -![Ticket Compliance](https://www.qodo.ai/images/pr_agent/ticket_compliance_review.png){width=768} +![Ticket Compliance](../assets/ticket_compliance_review.png){width=768} A `PR Code Verified` label indicates the PR code meets ticket requirements, but requires additional manual testing beyond the code scope. For example - validating UI display across different environments (Mac, Windows, mobile, etc.). @@ -147,7 +148,11 @@ the established ticket-extraction behavior of existing providers. ## Jira Integration -We support both Jira Cloud and Jira Server/Data Center. +Only **Jira Cloud** is supported. The base URL is derived from a validated site name +(`jira_site` → `https://.atlassian.net`) rather than taken as a free-form URL, so +the configured destination is always an Atlassian Cloud host. Jira Server / Data Center +(self-hosted) uses a free-form host and is not supported yet; it can be added once +base-URL handling for the self-hosted case is settled. ### Jira Cloud @@ -169,187 +174,48 @@ You can create an API token from your Atlassian account: ```toml [jira] -jira_api_token = "YOUR_API_TOKEN" +jira_site = "" # the "" in https://.atlassian.net (e.g. "mycompany") jira_api_email = "YOUR_EMAIL" +jira_api_token = "YOUR_API_TOKEN" ``` -### Jira Data Center/Server +`jira_site` is your Jira Cloud site name — the part before `.atlassian.net` (for +`https://mycompany.atlassian.net`, the site is `mycompany`). PR-Agent builds the base URL +as `https://.atlassian.net`; it does not accept a full URL, so configuration +cannot redirect the authenticated request to another host. Store `jira_api_email` and +`jira_api_token` as secrets (environment variables or the secrets file), not in +repository-committed configuration. -#### Using Basic Authentication for Jira Data Center/Server +#### Acceptance criteria / requirements (optional) -You can use your Jira username and password to authenticate with Jira Data Center/Server. - -In your Configuration file/Environment variables/Secrets file, add the following lines: +To include a ticket's acceptance criteria in the analysis, set `jira_requirements_field` +to the id of the custom field that holds it. The field id is specific to your Jira +instance (for example `customfield_10127`); leave it empty to skip requirements. ```toml -jira_api_email = "your_username" -jira_api_token = "your_password" +[jira] +jira_requirements_field = "customfield_10127" ``` -(Note that indeed the 'jira_api_email' field is used for the username, and the 'jira_api_token' field is used for the user password.) - -##### Validating Basic authentication via Python script - -If you are facing issues retrieving tickets in PR-Agent with Basic auth, you can validate the flow using a Python script. -This following steps will help you check if the basic auth is working correctly, and if you can access the Jira ticket details: - -1. run `pip install jira==3.8.0` - -2. run the following Python script (after replacing the placeholders with your actual values): - -???- example "Script to validate basic auth" - - ```python - from jira import JIRA - - - if __name__ == "__main__": - try: - # Jira server URL - server = "https://..." - # Basic auth - username = "..." - password = "..." - # Jira ticket code (e.g. "PROJ-123") - ticket_id = "..." - - print("Initializing JiraServerTicketProvider with JIRA server") - # Initialize JIRA client - jira = JIRA( - server=server, - basic_auth=(username, password), - timeout=30 - ) - if jira: - print(f"JIRA client initialized successfully") - else: - print("Error initializing JIRA client") - - # Fetch ticket details - ticket = jira.issue(ticket_id) - print(f"Ticket title: {ticket.fields.summary}") - - except Exception as e: - print(f"Error fetching JIRA ticket details: {e}") - ``` - -#### Using a Personal Access Token (PAT) for Jira Data Center/Server +#### Project key allowlist (optional) -1. Create a [Personal Access Token (PAT)](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) in your Jira account -2. In your Configuration file/Environment variables/Secrets file, add the following lines: +Ticket detection matches any `PROJECT-123` shaped text, so strings like `SHA-256`, +`UTF-8` or `ISO-8601` in a title or description each cost an authenticated lookup that +returns 404. If your repository works with a known set of Jira projects, list their keys +in `project_keys`; keys with any other prefix are then dropped before any lookup (they are +named once at debug level in the log). Leave the list empty to look up every key found. ```toml [jira] -jira_base_url = "YOUR_JIRA_BASE_URL" # e.g. https://jira.example.com -jira_api_token = "YOUR_API_TOKEN" +project_keys = ["PROJ", "OPS"] ``` -##### Validating PAT token via Python script - -If you are facing issues retrieving tickets in PR-Agent with PAT token, you can validate the flow using a Python script. -This following steps will help you check if the token is working correctly, and if you can access the Jira ticket details: - -1. run `pip install jira==3.8.0` - -2. run the following Python script (after replacing the placeholders with your actual values): - -??? example- "Script to validate PAT token" - - ```python - from jira import JIRA - - - if __name__ == "__main__": - try: - # Jira server URL - server = "https://..." - # Jira PAT token - token_auth = "..." - # Jira ticket code (e.g. "PROJ-123") - ticket_id = "..." - - print("Initializing JiraServerTicketProvider with JIRA server") - # Initialize JIRA client - jira = JIRA( - server=server, - token_auth=token_auth, - timeout=30 - ) - if jira: - print(f"JIRA client initialized successfully") - else: - print("Error initializing JIRA client") - - # Fetch ticket details - ticket = jira.issue(ticket_id) - print(f"Ticket title: {ticket.fields.summary}") - - except Exception as e: - print(f"Error fetching JIRA ticket details: {e}") - ``` - - -### Multi-JIRA Server Configuration - -PR-Agent supports connecting to multiple JIRA servers using different authentication methods. - -=== "Email/Token (Basic Auth)" - - Configure multiple servers using Email/Token authentication: - - - `jira_servers`: List of JIRA server URLs - - `jira_api_token`: List of API tokens (for Cloud) or passwords (for Data Center) - - `jira_api_email`: List of emails (for Cloud) or usernames (for Data Center) - - `jira_base_url`: Default server for ticket IDs like `PROJ-123`, Each repository can configure (local config file) its own `jira_base_url` to choose which server to use by default. - - **Example Configuration:** - ```toml - [jira] - # Server URLs - jira_servers = ["https://company.atlassian.net", "https://datacenter.jira.com"] - - # API tokens/passwords - jira_api_token = ["cloud_api_token_here", "datacenter_password"] - - # Emails/usernames (both required) - jira_api_email = ["user@company.com", "datacenter_username"] - - # Default server for ticket IDs - jira_base_url = "https://company.atlassian.net" - ``` - -=== "PAT Auth" - - Configure multiple servers using Personal Access Token authentication: - - - `jira_servers`: List of JIRA server URLs - - `jira_api_token`: List of PAT tokens - - `jira_api_email`: Not needed (can be omitted or left empty) - - `jira_base_url`: Default server for ticket IDs like `PROJ-123`, Each repository can configure (local config file) its own `jira_base_url` to choose which server to use by default. - - **Example Configuration:** - ```toml - [jira] - # Server URLs - jira_servers = ["https://server1.jira.com", "https://server2.jira.com"] - - # PAT tokens only - jira_api_token = ["pat_token_1", "pat_token_2"] - - # Default server for ticket IDs - jira_base_url = "https://server1.jira.com" - ``` - - **Mixed Authentication (Email/Token + PAT):** - ```toml - [jira] - jira_servers = ["https://company.atlassian.net", "https://server.jira.com"] - jira_api_token = ["cloud_api_token", "server_pat_token"] - jira_api_email = ["user@company.com", ""] # Empty for PAT - ``` - - - +Entries are plain upper-case project keys (letters only, as Jira writes them); anything +else (a lower-case label, a full ticket key, a URL, a blank entry) is ignored with a +warning. If the list is set but none of its entries is valid, no Jira lookup is made at all +until it is fixed, so a typo cannot silently widen the lookup again. Only a missing option, +the empty list, and an environment override set to the empty string mean "look up every +key". ### How to link a PR to a Jira ticket @@ -357,17 +223,8 @@ To integrate with Jira, you can link your PR to a ticket using either of these m **Method 1: Description Reference:** -Include a ticket reference in your PR description, using either the complete URL format `https://.atlassian.net/browse/ISSUE-123` or the shortened ticket ID `ISSUE-123` (without prefix or suffix for the shortened ID). +Include a ticket reference in your PR description, using either the complete URL format `https://.atlassian.net/browse/ISSUE-123` or the shortened ticket ID `ISSUE-123` (without prefix or suffix for the shortened ID). **Method 2: Branch Name Detection:** Name your branch with the ticket ID as a prefix (e.g., `ISSUE-123-feature-description` or `ISSUE-123/feature-description`). - -!!! note "Jira Base URL" - For shortened ticket IDs or branch detection (method 2 for JIRA cloud), you must configure the Jira base URL in your configuration file under the [jira] section: - - ```toml - [jira] - jira_base_url = "https://.atlassian.net" - ``` - Where `` is your Jira organization identifier (e.g., `mycompany` for `https://mycompany.atlassian.net`). diff --git a/docs/docs/core-abilities/index.md b/docs/docs/core-abilities/index.md index 8deff7aace..cd64123453 100644 --- a/docs/docs/core-abilities/index.md +++ b/docs/docs/core-abilities/index.md @@ -16,15 +16,15 @@ These resources provide more comprehensive insights into leveraging LLMs for sof ### Code Generation and LLMs -- [Effective AI code suggestions: less is more](https://www.codium.ai/blog/effective-code-suggestions-llms-less-is-more/) -- [State-of-the-art Code Generation with AlphaCodium – From Prompt Engineering to Flow Engineering](https://www.codium.ai/blog/qodoflow-state-of-the-art-code-generation-for-code-contests/) -- [RAG for a Codebase with 10k Repos](https://www.codium.ai/blog/rag-for-large-scale-code-repos/) +- [Effective AI code suggestions: less is more](https://www.qodo.ai/blog/effective-code-suggestions-llms-less-is-more/) +- [State-of-the-art Code Generation with AlphaCodium – From Prompt Engineering to Flow Engineering](https://www.qodo.ai/blog/qodoflow-state-of-the-art-code-generation-for-code-contests/) +- [RAG for a Codebase with 10k Repos](https://www.qodo.ai/blog/rag-for-large-scale-code-repos/) ### Development Processes -- [Understanding the Challenges and Pain Points of the Pull Request Cycle](https://www.codium.ai/blog/understanding-the-challenges-and-pain-points-of-the-pull-request-cycle/) -- [Introduction to Code Coverage Testing](https://www.codium.ai/blog/introduction-to-code-coverage-testing/) +- [Understanding the Challenges and Pain Points of the Pull Request Cycle](https://www.qodo.ai/blog/understanding-the-challenges-and-pain-points-of-the-pull-request-cycle/) +- [Introduction to Code Coverage Testing](https://www.qodo.ai/blog/introduction-to-code-coverage-testing/) ### Cost Optimization -- [Reduce Your Costs by 30% When Using GPT for Python Code](https://www.codium.ai/blog/reduce-your-costs-by-30-when-using-gpt-3-for-python-code/) +- [Reduce Your Costs by 30% When Using GPT for Python Code](https://www.qodo.ai/blog/reduce-your-costs-by-30-when-using-gpt-3-for-python-code/) diff --git a/docs/docs/core-abilities/metadata.md b/docs/docs/core-abilities/metadata.md index cd18e91bc9..2d6e2ea79c 100644 --- a/docs/docs/core-abilities/metadata.md +++ b/docs/docs/core-abilities/metadata.md @@ -1,6 +1,6 @@ # Local and global metadata injection with multi-stage analysis -`Supported Git Platforms: GitHub, GitLab, Bitbucket` +`Supported Git Platforms: GitHub, GitLab, Bitbucket, Azure DevOps, Gitea` 1\. PR-Agent initially retrieves for each PR the following data: diff --git a/docs/docs/core-abilities/self_reflection.md b/docs/docs/core-abilities/self_reflection.md index 990f724fbf..8a236e6b52 100644 --- a/docs/docs/core-abilities/self_reflection.md +++ b/docs/docs/core-abilities/self_reflection.md @@ -1,4 +1,4 @@ -`Supported Git Platforms: GitHub, GitLab, Bitbucket` +`Supported Git Platforms: GitHub, GitLab, Bitbucket, Azure DevOps, Gitea` PR-Agent implements a **self-reflection** process where the AI model reflects, scores, and re-ranks its own suggestions, eliminating irrelevant or incorrect ones. This approach improves the quality and relevance of suggestions, saving users time and enhancing their experience. @@ -38,8 +38,8 @@ This results in a more refined and valuable set of suggestions for the user, sav ## Example Results -![self_reflection](https://codium.ai/images/pr_agent/self_reflection1.png){width=768} -![self_reflection](https://codium.ai/images/pr_agent/self_reflection2.png){width=768} +![self_reflection](../assets/self_reflection1.png){width=768} +![self_reflection](../assets/self_reflection2.png){width=768} ## Appendix - Relevant Configuration Options diff --git a/docs/docs/index.md b/docs/docs/index.md index 6e4510c794..34d4487ae9 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -25,32 +25,34 @@ PR-Agent offers comprehensive pull request functionalities integrated with vario | [TOOLS](./tools/index.md) | [Describe](./tools/describe.md) | ✅ | ✅ | ✅ | ✅ | ✅ | | | [Review](./tools/review.md) | ✅ | ✅ | ✅ | ✅ | ✅ | | | [Improve](./tools/improve.md) | ✅ | ✅ | ✅ | ✅ | ✅ | -| | [Ask](./tools/ask.md) | ✅ | ✅ | ✅ | ✅ | | -| | ⮑ [Ask on code lines](./tools/ask.md#ask-lines) | ✅ | ✅ | | | | -| | [Add Docs](./tools/add_docs.md) | ✅ | ✅ | ✅ | ✅ | | -| | [Generate Labels](./tools/generate_labels.md) | ✅ | ✅ | ✅ | ✅ | | +| | [Ask](./tools/ask.md) | ✅ | ✅ | ✅ | ✅ | ✅ | +| | [Ask on code lines](./tools/ask.md#ask-lines) | ✅ | ✅ | | ✅ | | +| | [Add Docs](./tools/add_docs.md) | ✅ | ✅ | ✅ | ✅ | ✅ | +| | [Generate Labels](./tools/generate_labels.md) | ✅ | ✅ | 💬 | ✅ | | | | [Similar Issues](./tools/similar_issues.md) | ✅ | | | | | -| | [Help](./tools/help.md) | ✅ | ✅ | ✅ | ✅ | | -| | [Help Docs](./tools/help_docs.md) ⚠️ | — | — | — | | | -| | [Update CHANGELOG](./tools/update_changelog.md) | ✅ | ✅ | ✅ | ✅ | | +| | [Help](./tools/help.md) | ✅ | ✅ | ✅ | ✅ | ✅ | +| | [Help Docs](./tools/help_docs.md) ⚠️ | | | | | | +| | [Update CHANGELOG](./tools/update_changelog.md) | ✅ | ✅ | ✅ | 💬 | 💬 | | | | | | | | | | [USAGE](./usage-guide/index.md) | [CLI](./usage-guide/automations_and_usage.md#local-repo-cli) | ✅ | ✅ | ✅ | ✅ | ✅ | | | [App / webhook](./usage-guide/automations_and_usage.md#github-app) | ✅ | ✅ | ✅ | ✅ | ✅ | | | [Tagging bot](https://github.com/the-pr-agent/pr-agent#try-it-now) | ✅ | | | | | -| | [Actions](./installation/github.md#run-as-a-github-action) | ✅ | ✅ | ✅ | ✅ | | +| | [Actions](./installation/github.md#run-as-a-github-action)
[GitLab pipeline](./installation/gitlab.md#run-as-a-gitlab-pipeline)
[Bitbucket pipeline](./installation/bitbucket.md#run-as-a-bitbucket-pipeline)
[Azure DevOps pipeline](./installation/azure.md#azure-devops-pipeline) | ✅ | ✅ | ✅ | ✅ | | | | | | | | | | -| [CORE](./core-abilities/index.md) | [Adaptive and token-aware file patch fitting](./core-abilities/compression_strategy.md) | ✅ | ✅ | ✅ | ✅ | | +| [CORE](./core-abilities/index.md) | [Adaptive and token-aware file patch fitting](./core-abilities/compression_strategy.md) | ✅ | ✅ | ✅ | ✅ | ✅ | | | [Agent skills (`SKILL.md`)](./core-abilities/agent_skills.md) | ✅ | ✅ | ✅ | ✅ | ✅ | | | [Repo context files (`AGENTS.md`)](./usage-guide/additional_configurations.md#bringing-per-repo-context-files-to-pr-agent) | ✅ | ✅ | ✅ | ✅ | ✅ | -| | [Compression strategy](./core-abilities/compression_strategy.md) | ✅ | ✅ | ✅ | ✅ | | -| | [Dynamic context](./core-abilities/dynamic_context.md) | ✅ | ✅ | ✅ | ✅ | | -| | [Fetching ticket context](./core-abilities/fetching_ticket_context.md) | ✅ | ✅ | ✅ | | | -| | [Local and global metadata](./core-abilities/metadata.md) | ✅ | ✅ | ✅ | ✅ | | -| | [Multiple models support](./usage-guide/changing_a_model.md) | ✅ | ✅ | ✅ | ✅ | | -| | [Self reflection](./core-abilities/self_reflection.md) | ✅ | ✅ | ✅ | ✅ | | +| | [Compression strategy](./core-abilities/compression_strategy.md) | ✅ | ✅ | ✅ | ✅ | ✅ | +| | [Dynamic context](./core-abilities/dynamic_context.md) | ✅ | ✅ | ✅ | ✅ | ✅ | +| | [Fetching ticket context](./core-abilities/fetching_ticket_context.md) | ✅ | ✅ | ✅ | ✅ | ✅ | +| | [Local and global metadata](./core-abilities/metadata.md) | ✅ | ✅ | ✅ | ✅ | ✅ | +| | [Multiple models support](./usage-guide/changing_a_model.md) | ✅ | ✅ | ✅ | ✅ | ✅ | +| | [Self reflection](./core-abilities/self_reflection.md) | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ `/help_docs` is temporarily disabled since v0.36.1 pending a fix for a credential-exposure issue ([#2445](https://github.com/The-PR-Agent/pr-agent/issues/2445)); see [Help Docs](./tools/help_docs.md). +Legend: ✅ = supported. 💬 = the tool runs, but its output is posted as a PR comment instead of being applied (Update CHANGELOG where the provider cannot push files, Generate Labels where it cannot set labels). Blank = not supported, or not yet verified (Generate Labels on Gitea). + ## Example Results
@@ -58,21 +60,21 @@ PR-Agent offers comprehensive pull request functionalities integrated with vario #### [/describe](https://github.com/the-pr-agent/pr-agent/pull/530)
-![/describe](https://www.codium.ai/images/pr_agent/describe_new_short_main.png){width=512} +![/describe](assets/describe_new_short_main.webp){width=512}

#### [/review](https://github.com/the-pr-agent/pr-agent/pull/732#issuecomment-1975099151)
-![/review](https://www.codium.ai/images/pr_agent/review_new_short_main.png){width=512} +![/review](assets/review_new_short_main.png){width=512}

#### [/improve](https://github.com/the-pr-agent/pr-agent/pull/732#issuecomment-1975099159)
-![/improve](https://www.codium.ai/images/pr_agent/improve_new_short_main.png){width=512} +![/improve](assets/improve_new_short_main.webp){width=512}

@@ -80,6 +82,6 @@ PR-Agent offers comprehensive pull request functionalities integrated with vario The following diagram illustrates PR-Agent tools and their flow: -![PR-Agent Tools](https://codium.ai/images/pr_agent/diagram-v0.9.png) +![PR-Agent Tools](assets/diagram-v0.9.webp) Check out the [PR Compression strategy](core-abilities/index.md) page for more details on how we convert a code diff to a manageable LLM prompt diff --git a/docs/docs/installation/azure.md b/docs/docs/installation/azure.md index 817d67f669..61a61d93d9 100644 --- a/docs/docs/installation/azure.md +++ b/docs/docs/installation/azure.md @@ -59,7 +59,7 @@ stages: This script will run PR-Agent on every new merge request, with the `improve`, `review`, and `describe` commands. Note that you need to export the `azure_devops__pat` and `OPENAI_KEY` variables in the Azure DevOps pipeline settings (Pipelines -> Library -> + Variable group): -![PR-Agent](https://codium.ai/images/pr_agent/azure_devops_pipeline_secrets.png){width=468} +![PR-Agent](../assets/azure_devops_pipeline_secrets.png){width=468} Make sure to give pipeline permissions to the `pr_agent` variable group. diff --git a/docs/docs/installation/bitbucket.md b/docs/docs/installation/bitbucket.md index 56ba0b996a..ff82614ce2 100644 --- a/docs/docs/installation/bitbucket.md +++ b/docs/docs/installation/bitbucket.md @@ -28,6 +28,12 @@ For basic auth, you can generate a base64 encoded token from your username:passw Note that comments on a PR are not supported in Bitbucket Pipeline. +### Persistent comments on Bitbucket Cloud + +Review and code-suggestion identity markers use invisible Markdown link references on Bitbucket Cloud. +Existing comments with older HTML identity markers are still recognized and updated in place. +No configuration change is required. + ## Bitbucket Server and Data Center Login into your on-prem instance of Bitbucket with your service account username and password. diff --git a/docs/docs/installation/gitea.md b/docs/docs/installation/gitea.md index 827c362ecc..31ed76828b 100644 --- a/docs/docs/installation/gitea.md +++ b/docs/docs/installation/gitea.md @@ -10,6 +10,8 @@ WEBHOOK_SECRET=$(python -c "import secrets; print(secrets.token_hex(10))") ``` + The webhook secret is required: if `GITEA.WEBHOOK_SECRET` is not configured, the server rejects every incoming webhook with HTTP 403. + 4. Clone this repository: ```bash diff --git a/docs/docs/installation/github.md b/docs/docs/installation/github.md index 9e3da8a862..98f3da1f26 100644 --- a/docs/docs/installation/github.md +++ b/docs/docs/installation/github.md @@ -558,6 +558,12 @@ If you encounter rate limiting: ``` See the [Restricted Mode guide](../usage-guide/additional_configurations.md#restricted-mode) for details. +**Error: "PR-Agent command was not run" for incomplete GitHub files** +- **Cause**: GitHub limits pull-request changed-file responses to 3,000 files. A mismatch can also occur when GitHub + returns inconsistent file-count metadata. +- **Solution**: If the pull request changes more than 3,000 files, split it into smaller pull requests and run the + command again. Otherwise, retry the command. + **Error: "Invalid JSON format"** - **Solution**: Check that arrays are properly formatted as JSON strings: @@ -616,7 +622,7 @@ For more detailed configuration options, see: ... ``` - For enhanced security, you can also specify the Docker image by its [digest](https://hub.docker.com/repository/docker/pragent/pr-agent/tags). Resolve the digest for the version you are pinning with `docker buildx imagetools inspect pragent/pr-agent:0.41.0-github_action --format '{{.Manifest.Digest}}'`, then use it in place of the tag: + For enhanced security, you can also specify the Docker image by its [digest](https://hub.docker.com/r/pragent/pr-agent/tags). Resolve the digest for the version you are pinning with `docker buildx imagetools inspect pragent/pr-agent:0.41.0-github_action --format '{{.Manifest.Digest}}'`, then use it in place of the tag: ```yaml ... steps: @@ -845,6 +851,7 @@ Example IAM permissions to that user to allow access to CodeCommit: "codecommit:List*", "codecommit:PostComment*", "codecommit:PutCommentReaction", + "codecommit:UpdateComment", "codecommit:UpdatePullRequestDescription", "codecommit:UpdatePullRequestTitle" ], diff --git a/docs/docs/installation/gitlab.md b/docs/docs/installation/gitlab.md index d4ccbdae2d..c310e083bf 100644 --- a/docs/docs/installation/gitlab.md +++ b/docs/docs/installation/gitlab.md @@ -1,3 +1,21 @@ +## Merge request diff limits + +PR-Agent requires GitLab 15.7 or later and retrieves all pages from the merge request +[`/diffs` endpoint](https://docs.gitlab.com/api/merge_requests/#list-merge-request-diffs). +Pagination does not bypass GitLab's server-side diff limits. PR-Agent raises a provider +error if the returned file count disagrees with an exact `changes_count`, or that count +indicates overflow or is not ready. A file whose patch GitLab omits (`too_large`, or +`collapsed` before GitLab 19.2) is diffed locally from its two revisions instead. + +The `/diffs` endpoint must be available. PR-Agent does not fall back to the deprecated +`/changes` endpoint or its raw-diff retry. + +PR-Agent reads fresh merge request metadata before and after collecting the diff pages. +If the revision or file count changes during collection, it retries once, then raises a +provider error if they change again. Non-empty results also require usable base/head +references for loading file content. If the merge request moves after incremental +setup, PR-Agent falls back to a full review instead of mixing revisions. + ## Run as a GitLab Pipeline You can use a pre-built Action Docker image to run PR-Agent as a GitLab pipeline. This is a simple way to get started with PR-Agent without setting up your own server. @@ -88,6 +106,7 @@ Note that if your base branches are not protected, don't set the variables as `p > **Note**: The `gitlab__SSL_VERIFY` environment variable can be used to specify the path to a custom CA certificate bundle for SSL verification. GitLab exposes the `$CI_SERVER_TLS_CA_FILE` variable, which points to the custom CA certificate file configured in your GitLab instance. > Alternatively, SSL verification can be disabled entirely by setting `gitlab__SSL_VERIFY=false`, although this is not recommended. +> This setting affects only the GitLab API client. For certificate issues with LLM calls or git clone operations, see [Custom CA and Self-Signed Certificates](../usage-guide/custom_ca_and_self_signed_certificates.md). ## Run a GitLab webhook server @@ -116,7 +135,7 @@ git clone https://github.com/the-pr-agent/pr-agent.git 2. In the secrets file/variables: - Set your AI model key in the respective section - In the [gitlab] section, set `personal_access_token` (with token from step 2) and `shared_secret` (with secret from step 3) - - **Authentication type**: Set `auth_type` to `"private_token"` for older GitLab versions (e.g., 11.x) or private deployments. Default is `"oauth_token"` for gitlab.com and newer versions. + - **Authentication type**: Set `auth_type` to `"private_token"` to send the token in the `PRIVATE-TOKEN` header, or use the default `"oauth_token"` for the `Authorization: Bearer` header. Both are supported on GitLab.com and self-managed instances. 6. Build a Docker image for the app and optionally push it to a Docker repository. We'll use Dockerhub as an example: @@ -135,7 +154,7 @@ CONFIG__GIT_PROVIDER=gitlab GITLAB__PERSONAL_ACCESS_TOKEN= GITLAB__SHARED_SECRET= GITLAB__URL=https://gitlab.com -GITLAB__AUTH_TYPE=oauth_token # Use "private_token" for older GitLab versions +GITLAB__AUTH_TYPE=oauth_token # Use "private_token" for the PRIVATE-TOKEN header OPENAI__KEY= PORT=3000 # Optional: override the webhook server port ``` diff --git a/docs/docs/installation/index.md b/docs/docs/installation/index.md index e008d8a7aa..a8fa601ab4 100644 --- a/docs/docs/installation/index.md +++ b/docs/docs/installation/index.md @@ -23,7 +23,7 @@ behavior, not Requests-specific environment settings. ## Sizing a self-hosted webhook server -The GitHub, GitLab and Gitea webhook servers (the `github_app`, `gitlab_webhook` and `gitea_app` Docker targets) run under gunicorn with multiple worker processes, so that a worker busy handling a request cannot block the health check served by another. The other deployments — Bitbucket, Azure DevOps, GitHub polling, and the Lambda variants — run a single process and are unaffected by this section. +The GitHub, GitLab and Gitea webhook servers (the `github_app`, `gitlab_webhook` and `gitea_app` Docker targets) run under gunicorn with multiple worker processes, so that a worker busy handling a request cannot block the health check served by another. The other deployments — Bitbucket, Azure DevOps, GitHub polling, and the Lambda variants — do not use this gunicorn worker configuration. | Variable | Default | Description | |----------|---------|-------------| @@ -40,6 +40,24 @@ If the container is OOMKilled during startup, lower the worker count: GUNICORN_WORKERS=2 ``` +## GitHub polling workers + +GitHub polling uses one polling process and a separate child process for each +accepted comment. At most 10 of these child workers may be active across all +polling iterations. If an earlier batch still fills the limit, accepted work +waits for a free slot without blocking the event loop. This can delay the next +notification poll under sustained load. + +The existing per-batch limit is unchanged: only the first 10 queued comments in +a batch are accepted; excess work is logged and dropped. Waiting work is held in +memory only. Notification acknowledgement is unchanged, so shutdown or startup +failures do not guarantee redelivery. Dispatch interruptions log the remaining +count. A worker startup failure stops polling rather than risking further +dispatch with an untracked child; inspect the failure before restarting. +Completed children are joined without waiting and closed each iteration; +live children are not terminated on cancellation. This is not a durable queue, +a model-call deadline, or a host-wide memory limit. + !!! note "Docker Hub namespace migration" Releases **`0.34.2` and later** are published under [`pragent/pr-agent`](https://hub.docker.com/r/pragent/pr-agent). Older releases (up to and including `v0.31`) remain at the legacy [`codiumai/pr-agent`](https://hub.docker.com/r/codiumai/pr-agent) namespace as a frozen archive — no new images are pushed there. The examples on this site reference the new namespace; if you are pinning to a release before `0.34.2`, swap `pragent/pr-agent` for `codiumai/pr-agent` in your `image:` / `docker pull` / `uses: docker://` references. diff --git a/docs/docs/installation/locally.md b/docs/docs/installation/locally.md index 3dc7da908e..aa0f6ed18b 100644 --- a/docs/docs/installation/locally.md +++ b/docs/docs/installation/locally.md @@ -122,13 +122,23 @@ def main(): get_settings().set("github.user_token", user_token) # Run the command. Feedback will appear in GitHub PR comments - cli.run_command(pr_url, command) + return cli.run_command(pr_url, command) if __name__ == '__main__': - main() + raise SystemExit(main()) ``` +With `config.propagate_tool_errors` enabled, forwarding the return value through `SystemExit` makes this script exit with status 1 after a propagated tool error. The default remains status 0. + +The Python helper accepts quoted arguments, for example +`cli.run_command(pr_url, "/review --pr_reviewer.extra_instructions='be concise please'")`. +It uses the same quoting rules as configured automation commands: explicitly +quoted setting values stay strings, and unquoted values retain their normal +types. For questions containing apostrophes, use double quotes around the +question, such as `command = '/ask "What\'s changed?"'`. +This does not change how interactive PR comments are parsed. + ## Run from source 1. Clone this repository: diff --git a/docs/docs/installation/mosaico_server.md b/docs/docs/installation/mosaico_server.md new file mode 100644 index 0000000000..87b6667950 --- /dev/null +++ b/docs/docs/installation/mosaico_server.md @@ -0,0 +1,142 @@ +## MOSAICO A2A server + +PR-Agent can run as an [A2A](https://a2a-protocol.org/) 1.0 *solution agent* for the +[MOSAICO](https://mosaico-project.eu/) ecosystem: a small Starlette server that exposes the +standard A2A surface (agent card + JSON-RPC) plus a health probe. It is **not** a fork or a +separate project — the server is PR-Agent code under [`pr_agent/mosaico/`](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/mosaico/server.py), +ships in every release wheel, and ships as its own Docker image (`-mosaico_agent`) +starting at `v0.36.0`. The server is unbiased about the git provider: every request carries +either a PR URL or a raw diff, and the agent answers from that input alone. + +### What the mode is + +The A2A server exposes three endpoints: + +| Path | Method | Purpose | +| --- | --- | --- | +| `/.well-known/agent-card.json` | GET | The A2A agent card | +| `/` | POST | A2A 1.0 JSON-RPC. Sends the `SendMessage` method with an `A2A-Version: 1.0` header (the header is required: the server treats a request without it as protocol 0.3 and rejects it). The reply arrives as a task artifact (`result.task.artifacts[].parts[].text`), not as a status message. | +| `/health` | GET | A **live LLM connectivity probe** — `200` when an LLM round-trip succeeds, `503` otherwise | + +Images with health-probe hardening return `Unhealthy: LLM probe failed` for provider +failures; the health check's own warning records only the exception type. +`mosaico.health_timeout_seconds` sets a finite positive deadline in seconds (default: 10) +for cooperative asynchronous preparation and dispatch. Synchronous initialization and +blocking SDK work can exceed this deadline. Older images may predate these protections. +Set `MOSAICO__HEALTH_TIMEOUT_SECONDS` in the server's environment to override the default. +Invalid values produce the generic unhealthy response (503), rather than using the default. +When increasing the budget, also allow sufficient time in any external health-check client +and container healthcheck; the bundled Compose probe uses a separate 25-second HTTP timeout. + +The advertised agent card carries the skills `review`, `improve`, `describe`, and `ask`, the +name `"PR-Agent Solution Agent"`, a `version` derived from the running build (never +hand-maintained), and the required +`https://mosaico-project.eu/extensions/mosaico-observability` extension. Streaming is +advertised as disabled, which is load-bearing: the reference agent selects +`message/send` vs `message/stream` from that capability. + +### Run the standalone container + +The server boots from a bare `docker pull` in a couple of seconds — no repo clone, no build: + +```bash +docker pull pragent/pr-agent:0.41.0-mosaico_agent +docker run -d --name pr-agent-mosaico -p 9000:9000 \ + -e API_BASE=https://your-openai-compatible-endpoint/v1 \ + -e API_KEY=sk-... \ + -e MODEL_NAME=openai/your-model-slug \ + pragent/pr-agent:0.41.0-mosaico_agent + +curl -s http://localhost:9000/.well-known/agent-card.json | python3 -m json.tool +``` + +Pin a version tag in production (see the "Immutable releases and version tags" note on the +[installation page](./index.md)); the plain `mosaico_agent` +rolling tag moves to the newest build on every release. + +### Environment variables + +| Variable | Default | Purpose | +| --- | --- | --- | +| `API_BASE` | — | Base URL of the OpenAI-compatible LLM endpoint | +| `API_KEY` | — | API key for that endpoint | +| `MODEL_NAME` | — | Model slug to call | +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `9000` | Bind port | +| `AGENT_CARD_HOST`, `AGENT_CARD_PORT` | unset | URL advertised in the card's `supportedInterfaces`; see the warning below | +| `MODEL_MAX_TOKENS` | `32000` | Token budget for models whose context size pr-agent does not already know | +| `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` | unset | Optional Langfuse observability | + +!!! warning "AGENT_CARD_HOST / AGENT_CARD_PORT — the one thing to get right" + + These two variables set the URL the agent advertises in `supportedInterfaces`. Leave them + unset and the card advertises `http://localhost:9000/`, which is reachable only from + inside the container itself. The failure this causes is **silent and late**: registration + with MOSAICO succeeds, the repository stores the unreachable URL, and the reference agent + only fails to dereference it once it tries to route a task to this agent. Set them to + whatever host/port the *caller* will use to reach the container, and verify with: + + ```bash + curl -s http://:/.well-known/agent-card.json \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['supportedInterfaces'][0]['url'])" + ``` + + If that prints a `localhost` URL, the deployment is wrong. + +### Deploy into the mosaico-demonstrator + +The [`docker/mosaico/`](https://github.com/the-pr-agent/pr-agent/tree/main/docker/mosaico) +directory is a full deployment bundle (compose overlay, registration template, env template, +smoke test, LICENSE, and the canonical README). To run the agent as a task agent in the +[mosaico-demonstrator](https://gitlab.eclipse.org/eclipse-research-labs/mosaico-project/mosaico-demonstrator): + +1. Copy `docker-compose.pr-agent.yml` into the demonstrator's `compose/` directory, next to + `base-definitions.yml` (the overlay's `extends:` references resolve relative to that + directory). +2. Copy `pr-agent-solution-agent.json` into the demonstrator's + `docker/agent-registrations/` directory. +3. Append the "demonstrator overlay" block from `pr-agent.env.example` to the demonstrator's + `env/llm.env` and fill in `PR_AGENT_MODEL` (`PR_AGENT_HOST` may stay empty to use the + demonstrator's auto-detected LAN IP; `PR_AGENT_PORT` defaults to `23000`). +4. Add `-f compose/docker-compose.pr-agent.yml` to the demonstrator's `01-compose.sh`, next to + the other task-agent overlays. +5. Run `./01-compose.sh up -d`. + +The registration template carries only `description`, `role`, `objective`, `version`; the +demonstrator's `register-agent.py` injects `name`, `a2aAgentCardUrl`, and +`deployment.mode = ENDPOINT` at registration time. Two names are intentionally different and +should not be "fixed": the repository entry is `pr-agent-solution-agent` (what +`register-agent.py` looks the agent up by), while the card's own `name` is +`"PR-Agent Solution Agent"` (a display string). + +### Verify + +```bash +./smoke_test.sh +``` + +in the bundle directory gives one of two outcomes: + +- **`SMOKE PASSED`** — no LLM credentials were available; the script pulled the pinned image, + booted it, and validated the agent card only. +- **`FULL ROUND-TRIP PASSED`** — credentials were present (via a `.env` file beside the script, + copied from `pr-agent.env.example`); the script additionally exercised `GET /health` and an + A2A `SendMessage` review over an inline diff. + +### Troubleshooting + +- **The container stays `unhealthy` and registration never runs.** `/health` is a live LLM + probe and returns `503` on bad or missing credentials — this is intended. Check + `API_BASE` / `API_KEY` / `MODEL_NAME`, not the compose file. +- **The agent registers but the reference agent never reaches it.** The advertised card URL is + `localhost`; see the `AGENT_CARD_HOST` / `AGENT_CARD_PORT` warning above. +- **The registration container itself cannot fetch the agent card.** `01-compose.sh` falls back + to `get_fallback_ip`, which can resolve to `localhost` — reachable from the host but not from + inside the registration container on the Docker network. Set `PR_AGENT_HOST` explicitly to an + address reachable from inside Docker (for example the host's LAN IP, or `host.docker.internal`). + +### Keep reading + +The [bundle README](https://github.com/the-pr-agent/pr-agent/blob/main/docker/mosaico/README.md) +is the canonical deep dive for this surface and the source of the summary above; it covers the +upgrade procedure, the registration flow, and the full env-var contract in one place. diff --git a/docs/docs/summary.md b/docs/docs/summary.md index b4e71bb2f8..568c943baf 100644 --- a/docs/docs/summary.md +++ b/docs/docs/summary.md @@ -13,17 +13,21 @@ * [BitBucket Integration](installation/bitbucket.md) * [Azure DevOps Integration](installation/azure.md) * [Gitea Integration](installation/gitea.md) +* [MOSAICO A2A Server](installation/mosaico_server.md) ## Usage Guide * [Usage Guide](usage-guide/index.md) * [Introduction](usage-guide/introduction.md) * [Configuration File](usage-guide/configuration_options.md) +* [Configuration Reference](usage-guide/configuration_reference.md) * [Usage and Automation](usage-guide/automations_and_usage.md) * [Managing Mail Notifications](usage-guide/mail_notifications.md) +* [Push Outputs](usage-guide/push_outputs.md) * [Changing a Model](usage-guide/changing_a_model.md) * [Extending PR-Agent](usage-guide/extending_pr_agent.md) * [Additional Configurations](usage-guide/additional_configurations.md) +* [Custom CA and Self-Signed Certificates](usage-guide/custom_ca_and_self_signed_certificates.md) * [Plain-Diff Mode](usage-guide/plain_diff_mode.md) * [Local Git Provider](usage-guide/local_git_provider.md) * [Frequently Asked Questions](faq/index.md) diff --git a/docs/docs/tools/add_docs.md b/docs/docs/tools/add_docs.md index 02b5f6a37e..d5b926e484 100644 --- a/docs/docs/tools/add_docs.md +++ b/docs/docs/tools/add_docs.md @@ -12,11 +12,9 @@ It can be invoked manually by commenting on any PR: Invoke the tool manually by commenting `/add_docs` on any PR: -![Add Docs](https://codium.ai/images/pr_agent/add_docs_comment.png){width=512} +![Add Docs](../assets/add_docs_comment.png){width=512} -The tool will generate documentation suggestions as inline code suggestions: - -![Add Docs Result](https://codium.ai/images/pr_agent/add_docs_result.png){width=512} +The tool will generate documentation suggestions as inline code suggestions. ### Language-specific documentation styles diff --git a/docs/docs/tools/ask.md b/docs/docs/tools/ask.md index f87c982055..025d5a1d6e 100644 --- a/docs/docs/tools/ask.md +++ b/docs/docs/tools/ask.md @@ -9,9 +9,9 @@ It can be invoked manually by commenting on any PR: ## Example usage -![Ask Comment](https://codium.ai/images/pr_agent/ask_comment.png){width=512} +![Ask Comment](../assets/ask_comment.png){width=512} -![Ask](https://codium.ai/images/pr_agent/ask.png){width=512} +![Ask](../assets/ask.png){width=512} ## Ask lines @@ -21,7 +21,7 @@ You can run `/ask` on specific lines of code in the PR from the PR's diff view. - To select multiple lines, click on the '+' sign of the first line and then hold and drag to select the rest of the lines. - write `/ask "..."` in the comment box and press `Add single comment` button. -![Ask Line](https://codium.ai/images/pr_agent/Ask_line.png){width=512} +![Ask Line](../assets/Ask_line.png){width=512} Note that the tool does not have "memory" of previous questions, and answers each question independently. @@ -44,20 +44,20 @@ To get a direct link to an image, we recommend using the following scheme: 1\. First, post a comment that contains **only** the image: -![Ask image1](https://codium.ai/images/pr_agent/ask_images1.png){width=512} +![Ask image1](../assets/ask_images1.png){width=512} 2\. Quote reply to that comment: -![Ask image2](https://codium.ai/images/pr_agent/ask_images2.png){width=512} +![Ask image2](../assets/ask_images2.png){width=512} 3\. In the screen opened, type the question below the image: -![Ask image3](https://codium.ai/images/pr_agent/ask_images3.png){width=512} -![Ask image4](https://codium.ai/images/pr_agent/ask_images4.png){width=512} +![Ask image3](../assets/ask_images3.png){width=512} +![Ask image4](../assets/ask_images4.png){width=512} 4\. Post the comment, and receive the answer: -![Ask image5](https://codium.ai/images/pr_agent/ask_images5.png){width=512} +![Ask image5](../assets/ask_images5.png){width=512} See a full video tutorial [here](https://codium.ai/images/pr_agent/ask_image_video.mov) diff --git a/docs/docs/tools/describe.md b/docs/docs/tools/describe.md index 7cc1458ace..cca67d4e07 100644 --- a/docs/docs/tools/describe.md +++ b/docs/docs/tools/describe.md @@ -14,11 +14,11 @@ The tool can be triggered automatically every time a new PR is [opened](../usage Invoke the tool manually by commenting `/describe` on any PR: -![Describe comment](https://codium.ai/images/pr_agent/describe_comment.png){width=512} +![Describe comment](../assets/describe_comment.png){width=512} After ~30 seconds, the tool will generate a description for the PR: -![Describe New](https://codium.ai/images/pr_agent/describe_new.png){width=512} +![Describe New](../assets/describe_new.webp){width=512} If you want to edit [configurations](#configuration-options), add the relevant ones to the command: @@ -59,7 +59,7 @@ From our experience, there are two possible reasons: - When _updating_ PR descriptions, the `/describe` tool considers everything above the "PR Type" field as user content and will preserve it. Everything below this marker is treated as previously auto-generated content and will be replaced. -![Describe comment](https://codium.ai/images/pr_agent/pr_description_user_description.png){width=512} +![Describe comment](../assets/pr_description_user_description.png){width=512} ## Sequence Diagram Support The `/describe` tool includes a Mermaid sequence diagram showing component/function interactions. @@ -193,11 +193,11 @@ pr_agent:diagram The marker `pr_agent:type` will be replaced with the PR type, `pr_agent:summary` will be replaced with the PR summary, `pr_agent:walkthrough` will be replaced with the PR walkthrough, and `pr_agent:diagram` will be replaced with the sequence diagram (if enabled). -![Describe markers before](https://codium.ai/images/pr_agent/describe_markers_before.png){width=512} +![Describe markers before](../assets/describe_markers_before.png){width=512} becomes -![Describe markers after](https://codium.ai/images/pr_agent/describe_markers_after.png){width=512} +![Describe markers after](../assets/describe_markers_after.webp){width=512} **Configuration params**: @@ -258,7 +258,7 @@ Examples for custom labels: - ... The description should be comprehensive and detailed, indicating when to add the desired label. For example: -![Add native custom labels](https://codium.ai/images/pr_agent/add_native_custom_labels.png){width=768} +![Add native custom labels](../assets/add_native_custom_labels.webp){width=768} ## Usage Tips diff --git a/docs/docs/tools/generate_labels.md b/docs/docs/tools/generate_labels.md index ec6667a6f5..6ce1765238 100644 --- a/docs/docs/tools/generate_labels.md +++ b/docs/docs/tools/generate_labels.md @@ -10,13 +10,9 @@ It can be invoked manually by commenting on any PR: ## Example usage -Invoke the tool manually by commenting `/generate_labels` on any PR: +Invoke the tool manually by commenting `/generate_labels` on any PR. -![Generate Labels](https://codium.ai/images/pr_agent/generate_labels_comment.png){width=512} - -The tool will analyze the PR and add appropriate labels: - -![Generate Labels Result](https://codium.ai/images/pr_agent/generate_labels_result.png){width=512} +The tool will analyze the PR and add appropriate labels. ## Configuration options diff --git a/docs/docs/tools/help.md b/docs/docs/tools/help.md index 900587f772..54716269e9 100644 --- a/docs/docs/tools/help.md +++ b/docs/docs/tools/help.md @@ -13,9 +13,9 @@ It can be invoked manually by commenting on any PR: Invoke the `help` tool by commenting on a PR with: -![Help tool input](https://codium.ai/images/pr_agent/help1.png){width=750} +![Help tool input](../assets/help1.png){width=750} Response will include a list of available tools: -![Help tool output](https://codium.ai/images/pr_agent/help2.png){width=750} +![Help tool output](../assets/help2.webp){width=750} diff --git a/docs/docs/tools/help_docs.md b/docs/docs/tools/help_docs.md index 8a27c8c07c..d5a8ca7720 100644 --- a/docs/docs/tools/help_docs.md +++ b/docs/docs/tools/help_docs.md @@ -37,15 +37,15 @@ See more configuration options in the [Configuration options](#configuration-opt [//]: # (#### Asking a question about this repository:) -[//]: # (![help_docs on the documentation of this repository](https://codium.ai/images/pr_agent/help_docs_comment.png){width=512}) +[//]: # (![help_docs on the documentation of this repository](../assets/help_docs_comment.png){width=512}) **Asking a question about another repository** -![help_docs on the documentation of another repository](https://codium.ai/images/pr_agent/help_docs_comment_explicit_git.png){width=512} +![help_docs on the documentation of another repository](../assets/help_docs_comment_explicit_git.png){width=512} **Response**: -![help_docs response](https://codium.ai/images/pr_agent/help_docs_response.png){width=512} +![help_docs response](../assets/help_docs_response.png){width=512} ## Run automatically when a new issue is opened diff --git a/docs/docs/tools/improve.md b/docs/docs/tools/improve.md index 00396989ee..89a6621808 100644 --- a/docs/docs/tools/improve.md +++ b/docs/docs/tools/improve.md @@ -10,10 +10,10 @@ The tool can be triggered automatically every time a new PR is [opened](../usage ## How it looks === "Suggestions Overview" - ![code_suggestions_as_comment_closed](https://codium.ai/images/pr_agent/code_suggestions_as_comment_closed.png){width=512} + ![code_suggestions_as_comment_closed](../assets/code_suggestions_as_comment_closed.png){width=512} === "Selecting a specific suggestion" - ![code_suggestions_as_comment_open](https://codium.ai/images/pr_agent/code_suggestions_as_comment_open.png){width=512} + ![code_suggestions_as_comment_open](../assets/code_suggestions_as_comment_open.png){width=512} ___ @@ -45,7 +45,7 @@ When this fallback output succeeds, it is retained without an additional failure error propagation still follows `config.propagate_tool_errors`. If any individual retry succeeds, the existing partial-recovery behavior is preserved. -![improve](https://codium.ai/images/pr_agent/improve.png){width=512} +![improve](../assets/improve.png){width=512} ### Automatic triggering @@ -76,9 +76,9 @@ changes exits without calling the model. PR-Agent supports two modes for presenting code suggestions: -1) [Table](https://codium.ai/images/pr_agent/code_suggestions_as_comment_closed.png) mode +1) [Table](../assets/code_suggestions_as_comment_closed.png) mode -2) [Inline Committable](https://codium.ai/images/pr_agent/improve.png) code comments mode. +2) [Inline Committable](../assets/improve.png) code comments mode. The table format offers several key advantages: @@ -90,7 +90,7 @@ The table format offers several key advantages: Table mode is the default of PR-Agent, and is recommended approach for most users due to these benefits. -![code_suggestions_as_comment_closed.png](https://codium.ai/images/pr_agent/code_suggestions_as_comment_closed.png){width=512} +![code_suggestions_as_comment_closed.png](../assets/code_suggestions_as_comment_closed.png){width=512} Teams with specific preferences can enable committable code comments mode in their local configuration, or use [dual publishing mode](#dual-publishing-mode). @@ -122,25 +122,20 @@ Use triple quotes to write multi-line instructions. Use bullet points or numbers ### Best practices -`Platforms supported: GitHub, GitLab, Bitbucket` +`Repository context files supported: GitHub, GitLab, Gitea, Bitbucket, Azure DevOps` -!!! warning "Open-source PR-Agent" +The open-source PR-Agent package does not load `best_practices.md` automatically. To use a best-practices file, +add it to `config.repo_context_files`: - Automatic loading of `best_practices.md` is a Qodo Merge feature and is not available in the open-source - PR-Agent package. In the open-source package, add the file to `config.repo_context_files` instead: - - ```toml - [config] - repo_context_files = ["AGENTS.md", "best_practices.md"] - ``` - - This fallback supports GitHub, GitLab, Gitea, Bitbucket, and Azure DevOps. Repository context files are read - from the default branch by default and are limited by - `config.repo_context_max_lines` (500 lines by default). Set `config.repo_context_from_default_branch = false` - to read them from the pull request's target branch instead. Providers without repository file fetching log a - warning and skip this context. +```toml +[config] +repo_context_files = ["AGENTS.md", "best_practices.md"] +``` -Qodo Merge supports both simple and hierarchical best practices configurations to provide guidance to the AI model for generating relevant code suggestions. +This fallback supports GitHub, GitLab, Gitea, Bitbucket, and Azure DevOps. Repository context files are read +from the default branch by default and are limited by `config.repo_context_max_lines` (500 lines by default). +Set `config.repo_context_from_default_branch = false` to read them from the pull request's target branch instead. +Providers without repository file fetching log a warning and skip this context. ???- tip "Writing effective best practices files" @@ -149,10 +144,11 @@ Qodo Merge supports both simple and hierarchical best practices configurations t - Write clearly and concisely - Include brief code examples when helpful with before/after patterns - Focus on project-specific guidelines that will result in relevant suggestions you actually want to get - - Keep each file relatively short, under 800 lines, since: + - Keep each file short and focused, since: - AI models may not process effectively very long documents - Long files tend to contain generic guidelines already known to AI - - Maximum multiple file accumulated content is limited to 2000 lines. + - `config.repo_context_max_lines` limits the total rendered repository context, including wrapper and + truncation lines. - Use pattern-based structure rather than simple bullet points for better clarity ???- tip "Example of a best practices file" @@ -198,17 +194,11 @@ Qodo Merge supports both simple and hierarchical best practices configurations t return "" ``` -#### Local best practices in Qodo Merge - -For basic usage, create a `best_practices.md` file in your repository's root directory containing a list of best practices, coding standards, and guidelines specific to your repository. - -The AI model will use this `best_practices.md` file as a reference, and in case the PR code violates any of the guidelines, it will create additional suggestions, with a dedicated label: `Organization best practice`. - ### Combining 'extra instructions' and 'best practices' The `extra instructions` configuration is more related to the `improve` tool prompt. It can be used, for example, to avoid specific suggestions ("Don't suggest to add try-except block", "Ignore changes in toml files", ...) or to emphasize specific aspects or formats ("Answer in Japanese", "Give only short suggestions", ...) -In contrast, the `best_practices.md` file is a general guideline for the way code should be written in the repo. +In contrast, a `best_practices.md` file is a general guideline for the way code should be written in the repo. Using a combination of both can help the AI model to provide relevant and tailored suggestions. @@ -302,7 +292,7 @@ You can set the content of the checkbox text via: code_suggestions_self_review_text = "... (your text here) ..." ``` -![self_review_1](https://codium.ai/images/pr_agent/self_review_1.png){width=512} +![self_review_1](../assets/self_review_1.webp){width=512} !!! note "The checkbox is a visual marker only" @@ -417,6 +407,33 @@ for the authoritative default values. +## Recovering a partially failed analysis + +A partially failed analysis automatically tries the remaining configured +`config.fallback_models` for the chunks that raised an exception after other chunks succeeded, +with no extra configuration beyond `config.fallback_models` itself. +Successful predictions are retained in their original order. Recovery runs model by model, +after the preceding batch finishes, using the matching `openai.fallback_deployments` where configured, +and follows the effective fallback chain of the invocation, including a primary model selected by +[model routing](../usage-guide/changing_a_model.md#routing-small-pull-requests-to-a-cheaper-model). +A fallback model that recovers at least one chunk is marked as a fallback in the run +details, so the published run line still reports the primary model while the sticky fallback flag +stays set. + +Each remaining model is tried at most once per still-failed chunk at the tool level; the model +handler's existing retries and self-reflection calls can add provider requests. A chunk whose complete +generation prompt does not fit a fallback model's token budget is skipped for that model, without +truncating its context; the reserved headroom follows the output allowance the AI handler will +actually request for that model (`config.max_output_tokens` and related controls), falling back to +the fixed soft threshold when no specific limit is exposed. Later, larger models can still recover +it. Exhausted failures remain in the coverage notice. Cancellation propagates instead of starting +another retry. + +This is recovery within one invocation, not persisted resume across commands. Entirely failed initial +batches still use the existing whole-batch fallback. Valid empty suggestions are successful results; +responses rejected by the existing YAML parser retain its existing parse-failure behavior. +An ambiguous repeated model/deployment pair skips partial recovery rather than restarting the chain. + ## Understanding AI Code Suggestions - **AI Limitations:** AI models for code are getting better and better, but they are not flawless. Not all the suggestions will be perfect, and a user should not accept all of them automatically. Critical reading and judgment are required. Mistakes of the AI are rare but can happen, and it is usually quite easy for a human to spot them. diff --git a/docs/docs/tools/review.md b/docs/docs/tools/review.md index 5ff2b5ec4d..8cde64797e 100644 --- a/docs/docs/tools/review.md +++ b/docs/docs/tools/review.md @@ -10,7 +10,7 @@ The tool can be triggered automatically every time a new PR is [opened](../usage Note that the main purpose of the `review` tool is to provide the **PR reviewer** with useful feedback and insights. The PR author, in contrast, may prefer to save time and focus on the output of the [improve](./improve.md) tool, which provides actionable code suggestions. -(Read more about the different personas in the PR process and how PR-Agent aims to assist them in our [blog](https://www.codium.ai/blog/understanding-the-challenges-and-pain-points-of-the-pull-request-cycle/)) +(Read more about the different personas in the PR process and how PR-Agent aims to assist them in our [blog](https://www.qodo.ai/blog/understanding-the-challenges-and-pain-points-of-the-pull-request-cycle/)) ## Example usage @@ -18,11 +18,11 @@ Note that the main purpose of the `review` tool is to provide the **PR reviewer* Invoke the tool manually by commenting `/review` on any PR: -![review comment](https://codium.ai/images/pr_agent/review_comment.png){width=512} +![review comment](../assets/review_comment.png){width=512} After ~30 seconds, the tool will generate a review for the PR: -![review](https://codium.ai/images/pr_agent/review3.png){width=512} +![review](../assets/review3.png){width=512} If you want to edit [configurations](#configuration-options), add the relevant ones to the command: @@ -83,7 +83,7 @@ for the authoritative default values. persistent_finding_state - If set to true, PR-Agent persists structured review finding state across complete review runs, so findings can be resolved and reopened. Incremental and partial reviews do not resolve absent findings. Default is true. + If set to true, PR-Agent persists structured review finding state across complete review runs, so findings can be resolved and reopened. Resolved findings retain the original Markdown formatting of their description. Incremental and partial reviews do not resolve absent findings. Default is true. @@ -116,7 +116,7 @@ for the authoritative default values. inline_key_issues - Azure DevOps only. If set to true, each key issue is published as an inline thread. A finding leaves the review summary when a matching thread exists or Azure accepts the new thread. Findings that cannot be anchored or published stay in the summary. + If set to true, each key issue is published as an inline comment where the provider supports verified inline-comment publication. A finding leaves the review summary when a matching comment exists or the provider accepts the new comment. Findings that cannot be anchored or published stay in the summary. @@ -154,7 +154,11 @@ for the authoritative default values. require_ticket_analysis_review - If set to true, and the PR contains a GitHub or Jira ticket link, the tool will add a section that checks if the PR in fact fulfilled the ticket requirements. + + If set to true and ticket context is available, the tool adds a ticket-compliance section to the review comment + that checks whether the PR fulfills the ticket requirements. Supported sources include GitHub and GitLab issues, + linked Azure DevOps work items, Jira Cloud tickets, and Asana tasks. + require_risk_assessment @@ -213,11 +217,15 @@ for the authoritative default values. !!! tip "" - The `review` can tool automatically add labels to your Pull Requests: + The `review` tool can automatically add labels to your Pull Requests: - **`possible security issue`**: This label is applied if the tool detects a potential [security vulnerability](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/pr_reviewer_prompts.toml#L134) in the PR's code. This feedback is controlled by the 'enable_review_labels_security' flag (default is true). - **`review effort [x/5]`**: This label estimates the [effort](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/pr_reviewer_prompts.toml#L118) required to review the PR on a relative scale of 1 to 5, where 'x' represents the assessed effort. This feedback is controlled by the 'enable_review_labels_effort' flag (default is true). - - **`ticket compliance`**: Adds a label indicating code compliance level ("Fully compliant" | "PR Code Verified" | "Partially compliant" | "Not compliant") to any GitHub/Jira/Linea ticket linked in the PR. Controlled by the 'require_ticket_labels' flag (default: false). If 'require_no_ticket_labels' is also enabled, PRs without ticket links will receive a "No ticket found" label. + + Ticket compliance is reported in the review comment, not as a PR label. It is controlled by + `pr_reviewer.require_ticket_analysis_review` and requires available ticket context. The tool does not add + ticket-compliance labels or a "No ticket found" label. See + [Fetching ticket context](../core-abilities/fetching_ticket_context.md) for linking and authentication details. ### Auto-blocking PRs from being merged based on the generated labels @@ -226,7 +234,7 @@ for the authoritative default values. You can configure a CI/CD Action to prevent merging PRs with specific labels. For example, implement a dedicated [GitHub Action](https://medium.com/sequra-tech/quick-tip-block-pull-request-merge-using-labels-6cc326936221). - This approach helps ensure PRs with potential security issues or ticket compliance problems will not be merged without further review. + This approach helps ensure PRs with potential security issues will not be merged without further review. Since AI may make mistakes or lack complete context, use this feature judiciously. For flexibility, users with appropriate permissions can remove generated labels when necessary. When a label is removed, this action will be automatically documented in the PR discussion, clearly indicating it was a deliberate override by an authorized user to allow the merge. @@ -265,6 +273,18 @@ for the authoritative default values. after chunking are still listed in the coverage footer. Every chunk is a separate model call, so a chunked review costs roughly `max_number_of_calls` times a normal one. + If a chunk fails or returns malformed output, successful chunks are retained and fallback + models retry only the pending work. Pending chunks can be split for a smaller model, within + the call limit; a larger model can also include previously omitted files. Chunks that still + exceed the model's budget are not sent to it. + + If every fallback is exhausted, successful chunks are published as a partial review with a + failed-chunk coverage warning, even when `publish_output_no_suggestions = false`. Incomplete + reviews cannot resolve absent persistent findings. If no chunk succeeds, the review fails. + With `config.propagate_tool_errors = true`, an exhausted fallback chain still signals failure + to the caller after publishing the partial review and removing the progress comment. + Optional run details list all models that contributed to the merged review. + Each chunk answers the same questions about a different part of the PR, so the answers are merged field by field: diff --git a/docs/docs/tools/similar_issues.md b/docs/docs/tools/similar_issues.md index 741c79938b..76afd6281a 100644 --- a/docs/docs/tools/similar_issues.md +++ b/docs/docs/tools/similar_issues.md @@ -1,7 +1,9 @@ ## Overview +> **Note**: `/similar_issue` is an **experimental** feature. It works only on GitHub, carries a disproportionately large share of the project's dependency and configuration surface for a single-provider tool, and is therefore excluded from the v1 stability guarantees. No backend is tested against its real driver. + The similar issue tool retrieves the most similar issues to the current issue. -It can be invoked manually by commenting on any PR: +It is an issue-scoped command: comment `/similar_issue` on the issue (served by the GitHub Action; the GitHub App webhook only dispatches comments on pull requests), or run it from the CLI with `--issue_url`: ``` /similar_issue @@ -9,17 +11,31 @@ It can be invoked manually by commenting on any PR: ## Example usage -![similar_issue_original_issue](https://codium.ai/images/pr_agent/similar_issue_original_issue.png){width=768} +![similar_issue_original_issue](../assets/similar_issue_original_issue.png){width=768} + +![similar_issue_comment](../assets/similar_issue_comment.png){width=768} + +![similar_issue](../assets/similar_issue.png){width=768} -![similar_issue_comment](https://codium.ai/images/pr_agent/similar_issue_comment.png){width=768} +### Indexing and re-runs -![similar_issue](https://codium.ai/images/pr_agent/similar_issue.png){width=768} +To perform retrieval, the `similar_issue` tool indexes the repository's issues in the configured vector database. On every backend: -Note that to perform retrieval, the `similar_issue` tool indexes all the repo previous issues (once). +- The **first run** indexes up to `max_issues_to_scan` issues (default `500`). +- **Later runs** append newer issues, stopping at the first already-indexed one, so previously-indexed issues are not re-embedded. +- `force_update_dataset = true` makes every run re-index the whole repository: LanceDB deletes the repository's rows first, Pinecone and Qdrant upsert over the existing rows. +- `skip_comments = true` skips issue comments and embeds only the issue title and body. + +These keys live under the `[pr_similar_issue]` section. Each backend section below describes its own configuration and re-run behaviour. ### Selecting a Vector Database -Configure your preferred database by changing the `pr_similar_issue` parameter in `configuration.toml` file. +Configure your preferred database by setting the `vectordb` parameter under `[pr_similar_issue]` in `configuration.toml`: + +``` +[pr_similar_issue] +vectordb = "lancedb" # options: "pinecone", "lancedb", "qdrant" +``` #### Available Options @@ -29,6 +45,12 @@ Choose from the following Vector Databases: 2. Pinecone 3. Qdrant +#### LanceDB Configuration + +LanceDB is the default backend (`vectordb = "lancedb"`) and needs no external credentials. The index is stored in a local directory given by the `[lancedb] uri` key (default `./lancedb`). A single table (`codium-ai-pr-agent-issues`) is shared by every repository indexed into the same directory; rows are tagged with the repository name in the metadata. + +As described in [Indexing and re-runs](#indexing-and-re-runs), the first run creates the table and indexes up to `max_issues_to_scan` issues; later runs append newer issues until the first already-indexed one; `force_update_dataset = true` deletes the repository's rows and re-indexes them. + #### Pinecone Configuration To use Pinecone with the `similar issue` tool, add these credentials to `.secrets.toml` (or set as environment variables): @@ -36,10 +58,33 @@ To use Pinecone with the `similar issue` tool, add these credentials to `.secret ``` [pinecone] api_key = "..." -environment = "..." +cloud = "aws" +region = "us-east-1" ``` -These parameters can be obtained by registering to [Pinecone](https://app.pinecone.io/?sessionType=signup/). +The `cloud` value must be one of `aws`, `gcp` or `azure`, and `region` must be an +availability region offered by that cloud. These parameters can be obtained by +registering to [Pinecone](https://app.pinecone.io/?sessionType=signup/). Note that the +tool uses Pinecone's serverless index API; the former `environment` setting from the +gcp-starter pod tier is no longer supported. + +`cloud` and `region` are only used when the index does not exist yet and needs to be +created. An existing index is opened by name and is never recreated, so moving an +existing deployment to the new configuration does not lose the stored vectors. + +On re-runs, the first run creates the index and upserts up to `max_issues_to_scan` issues; later runs append newer issues until the first already-indexed one; `force_update_dataset = true` re-indexes the whole repository. + +!!! note "No backend is tested against its real driver" + + The `similar-issue` dependency group is not installed in CI, so the pinecone tests run + against a faked module, the qdrant tests never construct a client, and the lancedb tests + run against a fake table. + +!!! note "Default vector database" + + `vectordb` defaults to `lancedb`, which works with no external credentials. To use + qdrant or pinecone, set `vectordb = "qdrant"` or `vectordb = "pinecone"` under + `[pr_similar_issue]`. #### Qdrant Configuration @@ -60,7 +105,11 @@ vectordb = "qdrant" You can get a free managed Qdrant instance from [Qdrant Cloud](https://cloud.qdrant.io/). -Qdrant points are stored in a collection named `codium-ai-pr-agent-issues-v2`. +`api_key` must be present even when the server does not enforce authentication (an empty string is accepted): the tool reads both `url` and `api_key` and raises when either is absent. A re-index uploads the whole repository in a single request. + +Qdrant points are stored in a collection named `codium-ai-pr-agent-issues-v2`, derived by appending a `-v2` suffix to the shared index name (`codium-ai-pr-agent-issues`). The suffix is an implementation detail of the Qdrant backend only; pinecone and lancedb use the unsuffixed name. + +On re-runs, the first run creates the collection and stores up to `max_issues_to_scan` issues; later runs append newer issues until the first already-indexed one; `force_update_dataset = true` re-indexes the whole repository. !!! note "Upgrading an index created before the point-id fix" @@ -83,7 +132,7 @@ Qdrant points are stored in a collection named `codium-ai-pr-agent-issues-v2`. - To invoke the 'similar issue' tool from **CLI**, run: `uv run pr-agent --issue_url=... similar_issue` -- To invoke the 'similar' issue tool via online usage, [comment](https://github.com/the-pr-agent/pr-agent/issues/178#issuecomment-1716934893) on a PR: +- To invoke the 'similar' issue tool via online usage, [comment](https://github.com/the-pr-agent/pr-agent/issues/178#issuecomment-1716934893) on an issue: `/similar_issue` - You can also enable the 'similar issue' tool to run automatically when a new issue is opened, by adding it to the [pr_commands list in the github_app section](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) diff --git a/docs/docs/tools/update_changelog.md b/docs/docs/tools/update_changelog.md index 3064ba0855..c86e4e6bd8 100644 --- a/docs/docs/tools/update_changelog.md +++ b/docs/docs/tools/update_changelog.md @@ -9,15 +9,15 @@ It can be invoked manually by commenting on any PR: ## Example usage -![update_changelog_comment](https://codium.ai/images/pr_agent/update_changelog_comment.png){width=768} +![update_changelog_comment](../assets/update_changelog_comment.png){width=768} -![update_changelog](https://codium.ai/images/pr_agent/update_changelog.png){width=768} +![update_changelog](../assets/update_changelog.png){width=768} ## Configuration options Under the section `pr_update_changelog`, the [configuration file](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) contains options to customize the 'update changelog' tool: -- `push_changelog_changes`: whether to push the changes to CHANGELOG.md, or just publish them as a comment. Default is false (publish as comment). +- `push_changelog_changes`: whether to push the changes to CHANGELOG.md, or just publish them as a comment. Default is false (publish as comment). Before pushing, the tool requires a confirmed read of the existing CHANGELOG.md; a confirmed missing file is treated as empty, while other read failures skip the repository write, attempt to publish a not-pushed fallback comment, and surface the original error. If the repository write itself fails, the tool makes one best-effort attempt to publish the generated changelog as a fallback and surfaces the original write error. Because a transport failure may happen after a completed remote write, this fallback reports the repository update as unconfirmed rather than promising it was not pushed. - `extra_instructions`: Optional extra instructions to the tool. For example: "Use the following structure: ..." - `add_pr_link`: whether the model should try to add a link to the PR in the changelog. Default is true. - `skip_ci_on_push`: whether the commit message (when `push_changelog_changes` is true) will include the term "[skip ci]", preventing CI tests to be triggered on the changelog commit. Default is true. diff --git a/docs/docs/usage-guide/EXAMPLE_BEST_PRACTICE.md b/docs/docs/usage-guide/EXAMPLE_BEST_PRACTICE.md deleted file mode 100644 index 993c0246db..0000000000 --- a/docs/docs/usage-guide/EXAMPLE_BEST_PRACTICE.md +++ /dev/null @@ -1,190 +0,0 @@ -## Recommend Python Best Practices - -This document outlines a series of recommended best practices for Python development. These guidelines aim to improve code quality, maintainability, and readability. - -### Imports - -Use `import` statements for packages and modules only, not for individual types, classes, or functions. - -#### Definition - -Reusability mechanism for sharing code from one module to another. - -#### Decision - -- Use `import x` for importing packages and modules. -- Use `from x import y` where `x` is the package prefix and `y` is the module name with no prefix. -- Use `from x import y as z` in any of the following circumstances: - - Two modules named `y` are to be imported. - - `y` conflicts with a top-level name defined in the current module. - - `y` conflicts with a common parameter name that is part of the public API (e.g., `features`). - - `y` is an inconveniently long name, or too generic in the context of your code -- Use `import y as z` only when `z` is a standard abbreviation (e.g., `import numpy as np`). - -For example the module `sound.effects.echo` may be imported as follows: - -``` -from sound.effects import echo -... -echo.EchoFilter(input, output, delay=0.7, atten=4) - -``` - -Do not use relative names in imports. Even if the module is in the same package, use the full package name. This helps prevent unintentionally importing a package twice. - -##### Exemptions - -Exemptions from this rule: - -- Symbols from the following modules are used to support static analysis and type checking: - - [`typing` module](https://google.github.io/styleguide/pyguide.html#typing-imports) - - [`collections.abc` module](https://google.github.io/styleguide/pyguide.html#typing-imports) - - [`typing_extensions` module](https://github.com/python/typing_extensions/blob/main/README.md) -- Redirects from the [six.moves module](https://six.readthedocs.io/#module-six.moves). - -### Packages - -Import each module using the full pathname location of the module. - -#### Decision - -All new code should import each module by its full package name. - -Imports should be as follows: - -``` -Yes: - # Reference absl.flags in code with the complete name (verbose). - import absl.flags - from doctor.who import jodie - - _FOO = absl.flags.DEFINE_string(...) - -``` - -``` -Yes: - # Reference flags in code with just the module name (common). - from absl import flags - from doctor.who import jodie - - _FOO = flags.DEFINE_string(...) - -``` - -_(assume this file lives in `doctor/who/` where `jodie.py` also exists)_ - -``` -No: - # Unclear what module the author wanted and what will be imported. The actual - # import behavior depends on external factors controlling sys.path. - # Which possible jodie module did the author intend to import? - import jodie - -``` - -The directory the main binary is located in should not be assumed to be in `sys.path` despite that happening in some environments. This being the case, code should assume that `import jodie` refers to a third-party or top-level package named `jodie`, not a local `jodie.py`. - -### Default Iterators and Operators - -Use default iterators and operators for types that support them, like lists, dictionaries, and files. - -#### Definition - -Container types, like dictionaries and lists, define default iterators and membership test operators (“in” and “not in”). - -#### Decision - -Use default iterators and operators for types that support them, like lists, dictionaries, and files. The built-in types define iterator methods, too. Prefer these methods to methods that return lists, except that you should not mutate a container while iterating over it. - -``` -Yes: for key in adict: ... - if obj in alist: ... - for line in afile: ... - for k, v in adict.items(): ... -``` - -``` -No: for key in adict.keys(): ... - for line in afile.readlines(): ... -``` - -### Lambda Functions - -Okay for one-liners. Prefer generator expressions over `map()` or `filter()` with a `lambda`. - -#### Decision - -Lambdas are allowed. If the code inside the lambda function spans multiple lines or is longer than 60-80 chars, it might be better to define it as a regular [nested function](https://google.github.io/styleguide/pyguide.html#lexical-scoping). - -For common operations like multiplication, use the functions from the `operator` module instead of lambda functions. For example, prefer `operator.mul` to `lambda x, y: x * y`. - -### Default Argument Values - -Okay in most cases. - -#### Definition - -You can specify values for variables at the end of a function’s parameter list, e.g., `def foo(a, b=0):`. If `foo` is called with only one argument, `b` is set to 0. If it is called with two arguments, `b` has the value of the second argument. - -#### Decision - -Okay to use with the following caveat: - -Do not use mutable objects as default values in the function or method definition. - -``` -Yes: def foo(a, b=None): - if b is None: - b = [] -Yes: def foo(a, b: Sequence | None = None): - if b is None: - b = [] -Yes: def foo(a, b: Sequence = ()): # Empty tuple OK since tuples are immutable. - ... -``` - -``` -from absl import flags -_FOO = flags.DEFINE_string(...) - -No: def foo(a, b=[]): - ... -No: def foo(a, b=time.time()): # Is `b` supposed to represent when this module was loaded? - ... -No: def foo(a, b=_FOO.value): # sys.argv has not yet been parsed... - ... -No: def foo(a, b: Mapping = {}): # Could still get passed to unchecked code. - ... -``` - -### True/False Evaluations - -Use the “implicit” false if possible, e.g., `if foo:` rather than `if foo != []:` - -### Lexical Scoping - -Okay to use. - -An example of the use of this feature is: - -``` -def get_adder(summand1: float) -> Callable[[float], float]: - """Returns a function that adds numbers to a given number.""" - def adder(summand2: float) -> float: - return summand1 + summand2 - - return adder -``` - -#### Decision - -Okay to use. - -### Threading - -Do not rely on the atomicity of built-in types. - -While Python’s built-in data types such as dictionaries appear to have atomic operations, there are corner cases where they aren’t atomic (e.g. if `__hash__` or `__eq__` are implemented as Python methods) and their atomicity should not be relied upon. Neither should you rely on atomic variable assignment (since this in turn depends on dictionaries). - -Use the `queue` module’s `Queue` data type as the preferred way to communicate data between threads. Otherwise, use the `threading` module and its locking primitives. Prefer condition variables and `threading.Condition` instead of using lower-level locks. diff --git a/docs/docs/usage-guide/additional_configurations.md b/docs/docs/usage-guide/additional_configurations.md index d0e6bb81f3..b0c8475f3e 100644 --- a/docs/docs/usage-guide/additional_configurations.md +++ b/docs/docs/usage-guide/additional_configurations.md @@ -9,7 +9,7 @@ To print all the available configurations as a comment on your PR, you can use t /config ``` -![possible_config1](https://codium.ai/images/pr_agent/possible_config1.png){width=512} +![possible_config1](../assets/possible_config1.png){width=512} To view the **actual** configurations used for a specific tool, after all the user settings are applied, you can add for each tool a `--config.output_relevant_configurations=true` suffix. For example: @@ -20,7 +20,7 @@ For example: Will output an additional field showing the actual configurations used for the `improve` tool. -![possible_config2](https://codium.ai/images/pr_agent/possible_config2.png){width=512} +![possible_config2](../assets/possible_config2.png){width=512} ### Showing the agent run details @@ -149,6 +149,8 @@ expand_submodule_diffs = true When enabled, PR-Agent will fetch and attach diffs from the submodule repositories. The default is `false` to avoid extra GitLab API calls. +Submodule URLs in `.gitmodules` may be absolute (`https://`, `ssh://`, `git@host:`) or relative (`../group/repo.git`). Relative URLs are resolved against the merge request's project path the same way git does, so submodules that live in a sibling group on the same GitLab instance are expanded too. + ## Post the review as a GitLab thread By default, PR-Agent posts the `/review` summary as a plain note. To post it as a resolvable thread (GitLab discussion) instead, enable (default: `false`): @@ -336,7 +338,7 @@ Telemetry is disabled by default. To enable it, set in `configuration.toml`: ```toml [otel] is_enabled = true -exporter_type = "console" # "console", "otlp", or "none" +exporter_type = "console" # "console", "otlp", "prometheus", or "none" service_name = "pr-agent" environment = "development" # e.g. "development", "staging", "production" ``` @@ -362,6 +364,29 @@ otlp_protocol = "grpc" # default: "http" This is the recommended topology for fleets: point every PR-Agent instance at the same collector and aggregate there. Each process creates its own exporter connection; use the `service_name` and `environment` resource attributes to slice instances apart on the backend. +### Exposing native Prometheus metrics + +Instead of pushing to a collector, set `exporter_type = "prometheus"` to expose a native `GET /metrics` scrape endpoint on the gunicorn-served apps (`github_app`, `gitlab_webhook`, `azuredevops_server_webhook`, `gitea_app`). The command counter is translated into the Prometheus text format, and every gunicorn worker's values are merged at scrape time, so counters stay correct across the process workers: + +```toml +[otel] +exporter_type = "prometheus" +prometheus_multiproc_dir = "/tmp/pr-agent-prometheus" # shared, writable by every worker +``` + +1. The exporter is metrics-only: command spans are not exported in this mode. +2. `/metrics` is mounted only when this exporter is selected, so nothing is exposed by default. It does not depend on an OTLP collector or endpoint. +3. gunicorn registers and deregisters workers' state files automatically (`when_ready`/`child_exit`); a worker that dies mid-scrape leaves only a stale file, which is ignored once it is marked dead. +4. Metric families are created from the first data point's label set. Later attributes that do not fit the family are dropped, and missing ones are back-filled with an empty string, so a scrape never breaks on drifting label cardinality. +5. The exporter ships with PR-Agent (it depends on `prometheus-client`); no extra package is required. Scrape it like any exporter: + +```yaml +scrape_configs: + - job_name: pr-agent + static_configs: + - targets: ["pr-agent:3000"] +``` + Privacy controls (both off by default): - `include_pr_url = true` attaches PR URLs to spans. Off by default because URLs expose private repo names. @@ -373,6 +398,7 @@ Notes: - PR-Agent keeps its own OpenTelemetry providers and never registers the process-global one, so embedding PR-Agent in an application that already uses OpenTelemetry will not interfere with the host's telemetry. Pending spans and metrics are flushed automatically on process exit. - Each OTLP export call is bounded by `otlp_timeout` (default 3 seconds, retries included), so an unreachable collector cannot hang CLI exit or request completion. Raise it for slow collectors at the cost of longer worst-case stalls. - If `exporter_type = "otlp"` is set but no endpoint is configured, telemetry is disabled entirely (fail closed) — it never falls back to another exporter, so a missing secret cannot redirect telemetry into process logs. +- With `exporter_type = "prometheus"`, `prometheus_multiproc_dir` must be a shared directory writable by every worker; it defaults to `/tmp/pr-agent-prometheus`. In non-gunicorn (single-process) deployments the exporter works without it and simply serves the process's own registry. - **Serverless deployments** (e.g. the AWS Lambda webhooks) are supported: buffered spans and metrics are force-flushed at the end of every handled request, because frozen execution environments stop background export threads and are reaped without running exit handlers. No extra configuration is needed. ## Bringing per-repo context files to PR-Agent @@ -412,6 +438,41 @@ To bound how much of this context is sent to the model, `repo_context_max_lines` repo_context_max_lines = 500 ``` +### Context from sibling repositories + +The host operator must first approve repositories in the deployment configuration: + +```toml +[config] +repo_context_sibling_repos = ["my-group/library"] +repo_context_max_sibling_files = 5 +``` + +Approve only repositories whose contents may appear in reviews of consuming repositories. +This allowlist and the fetch limit cannot be changed by repository settings or comment arguments. +An empty allowlist disables sibling reads. + +A consuming repository can then select files in its `.pr_agent.toml` as structured +`repo_context_files` entries: + +```toml +[config] +repo_context_files = [ + "AGENTS.md", + {repo_id = "my-group/library", file_path = "src/api.py"}, +] +``` + +Use `owner/repository` on GitHub or a full project path on GitLab. GitLab also accepts numeric +project IDs as strings, provided the same identifier is in the host allowlist. Resolved repositories +must share the current repository's owning namespace: the GitHub owner or GitLab top-level group, +including projects in different subgroups. Renamed or redirected paths must be updated to their +canonical names in both settings. + +Sibling files always come from their default branch and share `repo_context_max_lines` with local +files. Private and internal repositories also require requester access. Comment arguments cannot +override `repo_context_files` at all; a repository's `.pr_agent.toml` may still set it. + ## Ignoring automatic commands in PRs PR-Agent allows you to automatically ignore certain PRs based on various criteria: @@ -517,19 +578,6 @@ ignore_language_framework = ['protobuf', ...] You can view the list of auto-generated file patterns in [`generated_code_ignore.toml`](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/generated_code_ignore.toml). Files matching these glob patterns will be automatically excluded from PR Agent analysis. -### Ignoring Tickets with Specific Labels - -When PR-Agent analyzes tickets (JIRA, GitHub Issues, GitLab Issues, etc.) referenced in your PR, you may want to exclude tickets that have certain labels from the analysis. This is useful for filtering out tickets marked as "ignore-compliance", "skip-review", or other labels that indicate the ticket should not be considered during PR review. - -To ignore tickets with specific labels, add the following to your `configuration.toml` file: - -```toml -[config] -ignore_ticket_labels = ["ignore-compliance", "skip-review", "wont-fix"] -``` - -Where `ignore_ticket_labels` is a list of label names that should be ignored during ticket analysis. - ### Restricted Mode When running PR-Agent with limited GitHub/GitLab permissions, set `restricted_mode` to `true` to gracefully skip operations that require elevated access (e.g., pushing changelog changes): diff --git a/docs/docs/usage-guide/automations_and_usage.md b/docs/docs/usage-guide/automations_and_usage.md index cadd1830e2..09618a7ec7 100644 --- a/docs/docs/usage-guide/automations_and_usage.md +++ b/docs/docs/usage-guide/automations_and_usage.md @@ -34,6 +34,14 @@ This is useful for debugging or experimenting with different tools. 3. **git provider**: The [git_provider](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) field in a configuration file determines the GIT provider that will be used by PR-Agent. Currently, the following providers are supported: `github` **(default)**, `gitlab`, `bitbucket`, `azure`, `codecommit`, `local`, and `gitea`. +4. For scripts that need a failed request to return a non-zero process status, enable tool error propagation: + +```bash +python -m pr_agent.cli --pr_url= review --config.propagate_tool_errors=true +``` + +When a propagated tool error makes the request fail, the installed `pr-agent` command, `python -m pr_agent.cli`, and the customizable pip script exit with status 1. The default remains compatible with existing CLI behavior and exits with status 0; argparse parse and usage errors continue to exit with status 2. + ### CLI Health Check To verify that PR-Agent has been configured correctly, you can run this health check command from the repository root: @@ -93,8 +101,7 @@ When this parameter is set to `true`, PR-Agent will not run any automatic tools ### GitHub App !!! note "Configurations for PR-Agent" - PR-Agent for GitHub is an App, hosted by Codium. So all the instructions below are relevant for PR-Agent users. - Same goes for [GitLab webhook](#gitlab-webhook) and [BitBucket App](#bitbucket-app) sections. + These settings apply to self-hosted GitHub App, GitLab webhook, and Bitbucket App deployments. #### GitHub app automatic tools when a new PR is opened @@ -237,7 +244,7 @@ env: Review result is output as JSON to `steps.{step-id}.outputs.review` property. The JSON structure is equivalent to the yaml data structure defined in [pr_reviewer_prompts.toml](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/pr_reviewer_prompts.toml). -`github.publish_as_check_run` controls whether tool output (review, describe, improve) is published as a GitHub Check Run instead of a PR comment (default is `false`). When enabled, results appear in the "Checks" tab of the PR. Requires `checks: write` permission in the workflow YAML. +`github.publish_as_check_run` controls whether tool output (review, describe, improve) is published as a GitHub Check Run instead of a PR comment (default is `false`). When enabled, results appear in the "Checks" tab of the PR. Requires `checks: write` permission in the workflow YAML. On the GitHub App, each automatic command opens its check run as in progress before the tool runs, so the author sees that PR-Agent picked the pull request up before any output exists; the run is completed with the tool's output, or marked failed if the command did not finish. Note that you can give additional config parameters by adding environment variables to `.github/workflows/pr_agent.yml`, or by using a `.pr_agent.toml` [configuration file](./configuration_options.md#global-configuration-file) in the root of your repo diff --git a/docs/docs/usage-guide/changing_a_model.md b/docs/docs/usage-guide/changing_a_model.md index 15b1d7a7fc..dda35905ab 100644 --- a/docs/docs/usage-guide/changing_a_model.md +++ b/docs/docs/usage-guide/changing_a_model.md @@ -20,6 +20,11 @@ You can give parameters via a configuration file, or from environment variables. See [litellm documentation](https://litellm.vercel.app/docs/proxy/quick_start#supported-llms) for the environment variables needed per model, as they may vary and change over time. Our documentation per-model may not always be up-to-date with the latest changes. Failing to set the needed keys of a specific model will usually result in litellm not identifying the model type, and failing to utilize it. +!!! warning "Credential isolation boundary" + PR-Agent captures request settings and supported provider environment values per handler. Deployment-owned LiteLLM secret managers are outside this request-isolation boundary; their credentials are not snapshotted by PR-Agent. Keep process environment variables and LiteLLM globals stable while requests run. The handler does not isolate arbitrary changes made by embedding applications during a request. Use separate processes when workloads require different deployment-owned credential sources or mutable global authentication and routing state. + + Process-wide routing fallbacks such as `litellm.api_base`, `litellm.api_version`, `litellm.organization`, `litellm.vertex_project`, and `litellm.vertex_location` can be rejected even when unchanged. Use the corresponding PR-Agent settings (`OPENAI.API_BASE`, `OPENAI.API_VERSION`, `OPENAI.ORG`, `VERTEXAI.VERTEX_PROJECT`, and `VERTEXAI.VERTEX_LOCATION`) or supported provider environment variables, and clear the corresponding LiteLLM globals even when they match the intended routing. Use `LITELLM.EXTRA_HEADERS` instead of `litellm.headers`. + ### OpenAI like API To use an OpenAI like API, set the following in your `.secrets.toml` file: @@ -43,7 +48,7 @@ To reduce costs for non-urgent/background tasks, enable Flex Processing: ```toml [litellm] -extra_body='{"processing_mode": "flex"}' +extra_body='{"service_tier": "flex"}' ``` See [OpenAI Flex Processing docs](https://platform.openai.com/docs/guides/flex-processing) for details. @@ -79,6 +84,10 @@ tenant_id = "" # Your Azure AD tenant ID api_base = "" # Your Azure OpenAI service base URL (e.g., https://openai.xyz.com/) ``` +The request-local Azure OIDC bridge captures `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_AUTHORITY_HOST`, `AZURE_SCOPE`, and competing `AZURE_CLIENT_SECRET` / `AZURE_USERNAME` / `AZURE_PASSWORD` settings when the handler is initialized. It preserves native authentication precedence while binding companion credentials and their cache identity to the captured authority; an absent authority uses the Azure public-cloud default, while an empty authority is rejected for companion credentials. LiteLLM's native assertion and secret-selector resolvers still control updates and source selection; their underlying deployment-owned credential chains are not request-isolated. Keep those sources scoped to the intended workload identity, or use separate processes for distinct identities. + +For native Azure SDK routes other than Cloudflare gateways, ordinary AD tokens use the same captured companion settings and SDK client-cache isolation. Azure Responses routes also bind ordinary AD companion selection to the captured settings. Complete companion credentials can also be selected without an initial AD token, including on native Azure AI raw HTTP routes. Captured companion providers retain callable token refresh and native authentication precedence. If SDK initialization or Responses token resolution reaches LiteLLM's implicit credential discovery with `enable_azure_ad_token_refresh=True`, the request is rejected: that fallback re-reads process-wide identity settings. Configure complete client-secret or username/password companion credentials instead. Managed identity, certificate, and default credential-chain discovery through this fallback are intentionally unsupported; an already cached, isolated SDK client can still be reused without entering discovery. + Passing custom headers to the underlying LLM Model API can be done by setting extra_headers parameter to litellm. ```toml @@ -88,6 +97,8 @@ extra_headers='{"projectId": "", ...}') #The value of thi This enables users to pass authorization tokens or API keys, when routing requests through an API management gateway. +Requests that would otherwise inherit non-empty process-wide `litellm.headers` are rejected, even for non-authentication headers; configure headers through `LITELLM.EXTRA_HEADERS` instead. + ### Ollama You can run models locally through either [VLLM](https://docs.litellm.ai/docs/providers/vllm) or [Ollama](https://docs.litellm.ai/docs/providers/ollama) @@ -105,7 +116,7 @@ duplicate_examples=true # will duplicate the examples in the prompt, to help the api_base = "http://localhost:11434" # or whatever port you're running Ollama on ``` -By default, Ollama uses a context window size of 2048 tokens. In most cases this is not enough to cover pr-agent prompt and pull-request diff. Context window size can be overridden with the `OLLAMA_CONTEXT_LENGTH` environment variable. For example, to set the default context length to 8K, use: `OLLAMA_CONTEXT_LENGTH=8192 ollama serve`. More information you can find on the [official ollama faq](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-can-i-specify-the-context-window-size). +By default, Ollama uses a context window size of 2048 tokens. In most cases this is not enough to cover pr-agent prompt and pull-request diff. Context window size can be overridden with the `OLLAMA_CONTEXT_LENGTH` environment variable. For example, to set the default context length to 8K, use: `OLLAMA_CONTEXT_LENGTH=8192 ollama serve`. More information you can find on the [official ollama faq](https://docs.ollama.com/faq#how-can-i-specify-the-context-window-size). Please note that the `custom_model_max_tokens` setting should be configured in accordance with the `OLLAMA_CONTEXT_LENGTH`. Failure to do so may result in unexpected model output. @@ -218,6 +229,12 @@ Your [application default credentials](https://cloud.google.com/docs/authenticat If you do want to set explicit credentials, then you can use the `GOOGLE_APPLICATION_CREDENTIALS` environment variable set to a path to a json credentials file. +Each handler captures the selected Cloud SDK ADC file and its resource project configuration, including `CLOUDSDK_CONFIG`, the active named configuration, and `CLOUDSDK_CORE_PROJECT`. Later changes apply to new handlers, not existing ones. The quota project remains separate from the resource project. When no ADC file exists at initialization, the handler retains managed-runtime discovery rather than adopting a file created later. + +AWS-backed Vertex workload identity federation captures environment credentials and region per handler. Different captured identities use separate credential caches even when they share the same WIF configuration; identical snapshots can reuse a cache entry. Metadata-backed credentials continue refreshing through the captured metadata source. + +Vertex external-account credentials with an executable source are intentionally unsupported, including when `GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1`. The helper and its cached output can change identity after a handler captures its configuration, so Vertex requests reject this source before using it for authentication. Use a non-executable credential source configured for the intended identity instead; normal credential refresh remains enabled for supported sources. + ### Google AI Studio To use [Google AI Studio](https://aistudio.google.com/) models, set the relevant models in the configuration section of the configuration file: @@ -299,6 +316,48 @@ Set `AWS_USE_IMDS=true` in the environment. PR-Agent will resolve credentials vi | EKS pod with IRSA | Web identity token + STS | | Lambda function | Runtime-injected credentials | +Credential discovery runs during handler initialization. Before each eligible SigV4 request, refresh through the +same boto3 credentials object runs in a background thread. Non-AWS and bearer-authenticated requests do not trigger +this refresh. PR-Agent passes a request-local snapshot to LiteLLM without writing credentials into the process +environment. Constructor discovery and credential-file fingerprinting remain synchronous. + +AWS calls using this provider chain remain serialized within a handler, including any static-credential retry. +Cancelling a request does not stop an already-running boto3 refresh, but its result cannot overwrite the handler's +credentials or static-fallback decision. A separate lock serializes SDK refreshes, including cancelled callers' +unfinished work. Refresh uses the event loop's shared default executor: blocked operations and workers waiting for +the SDK lock after repeated cancellations can delay unrelated executor work and process shutdown. No service-wide +worker quota or additional SDK timeout is introduced. + +The same opt-in is required for other boto3 provider-chain sources, including `AWS_PROFILE` and shared credentials +files. LiteLLM-specific `AWS_PROFILE_NAME` and `AWS_ROLE_NAME` selectors are not supported because they can override +request-local credentials; unset them and use `AWS_USE_IMDS=true` instead. + +When upgrading from implicit LiteLLM credential-chain discovery, set `AWS_USE_IMDS=true` explicitly. Without this +opt-in, PR-Agent only uses complete static credentials from settings or environment credentials captured when the +handler is initialized; it does not ask LiteLLM to discover a role or profile at request time. + +For classic Bedrock, a supported model ARN supplies the request region ahead of environment/settings regions, including during static-credential retries. Converse also uses a captured `litellm.model_id` ARN, or a region/model path when `model_id` is absent; Invoke does not derive its region from the separate `model_id`. Otherwise, without `AWS_USE_IMDS=true`, set `aws.AWS_REGION_NAME`, `AWS_REGION_NAME`, `AWS_REGION`, or `AWS_DEFAULT_REGION`. Complete environment credentials alone do not enable region discovery from boto3 profiles or LiteLLM's default region. Static credentials in `[aws]` still require `AWS_REGION_NAME`. + +For Bedrock Mantle without IMDS or complete static credentials, the region comes from `BEDROCK_MANTLE_REGION`, `AWS_REGION_NAME`, `aws.AWS_REGION_NAME`, or `AWS_REGION`, then defaults to `us-east-1`; `AWS_DEFAULT_REGION` alone does not change that default. A `BEDROCK_MANTLE_API_BASE` on the standard host `https://bedrock-mantle..api.aws` supplies the region and overrides these sources; a custom endpoint host does not. Complete static credentials and opted-in boto3 discovery retain the AWS region policy described above. + +Without `AWS_USE_IMDS=true`, environment authentication follows LiteLLM's `AWS_SESSION_TOKEN` selection. The legacy `AWS_SECURITY_TOKEN` alias is recognized only by the opted-in boto3 chain, which prefers it over `AWS_SESSION_TOKEN` when both are nonempty. + +Bedrock bearer tokens must come from handler-captured credentials rather than process-wide LiteLLM fallback, and `AWS_BEARER_TOKEN_BEDROCK` must be unset for `sagemaker_chat` and `sagemaker_nova` routes. + +When resolving credentials through profiles with `AWS_USE_IMDS=true`, PR-Agent does not execute +`credential_process` in the selected profile or its `source_profile` chain. If such a process is configured, +PR-Agent uses complete static credentials from `[aws]` (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and +`AWS_REGION_NAME`, plus `AWS_SESSION_TOKEN` when required) instead; without them, credential resolution fails. +This restriction does not apply when using complete environment credentials captured at handler initialization. + +Workload token files selected by `AWS_WEB_IDENTITY_TOKEN_FILE`, profile `web_identity_token_file`, or +`AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE` must be controlled by the deployment, not switched between tenants. +PR-Agent retains native token reload and credential refresh from the selected source; it does not freeze token +contents or detect a different identity replacing the contents at the same path. Use separately controlled +credential sources and appropriate process/container isolation for distinct workload identities, or supply +explicit request-local credentials. Request-local configuration is not an isolation boundary against arbitrary +code running in the same process. + Minimal GitHub Actions workflow (no AWS secret keys required): ```yaml @@ -321,7 +380,7 @@ The IAM role must have `bedrock:InvokeModel` permission on the target model ARN, } ``` -If you also configure static keys in `[aws]`, they serve as an automatic fallback: if the ambient credentials fail a Bedrock call (e.g., the role lacks `bedrock:InvokeModel`), PR-Agent retries with the static keys and logs a warning. +If you also configure static keys in `[aws]`, they serve as an automatic fallback when ambient credentials cannot be resolved or a SigV4 call using the opted-in provider chain raises an API error other than a rate-limit error. This applies to Bedrock, Bedrock Mantle, and SageMaker routes and preserves the existing fallback behavior for IAM authorization and connection failures, using only the static credentials captured by the handler. #### Custom Inference Profiles @@ -343,6 +402,33 @@ model_id = "your-application-inference-profile-arn" The `litellm.model_id` parameter applies only to classic `bedrock/` calls made through the `bedrock-runtime` APIs. It does not apply to `bedrock_mantle/`; for cost allocation with the Mantle Chat Completions and Responses APIs, use [Amazon Bedrock Projects](https://docs.aws.amazon.com/bedrock/latest/userguide/cost-mgmt-projects.html). +#### Claude 5 thinking with an application inference profile ARN + +Claude Sonnet 5 on Bedrock is invoked through an inference profile rather than a direct +foundation-model id. When that profile is an application inference profile, its ARN is an +opaque value that carries no model name. Add that ARN to +`claude_adaptive_thinking_models_override` so PR-Agent and LiteLLM both treat it as an +adaptive-thinking model: + +Use the ARN as the model id and repeat that exact value in the override: + +```toml +[config] # in configuration.toml +model = "bedrock/converse/arn:aws:bedrock:eu-central-1::application-inference-profile/" +enable_claude_adaptive_thinking = true +claude_adaptive_thinking_models_override = [ + "bedrock/converse/arn:aws:bedrock:eu-central-1::application-inference-profile/" +] +``` + +The override is additive, so named Claude models in the same fallback chain continue to use +built-in detection. PR-Agent also registers each override with LiteLLM, preventing LiteLLM from +converting the adaptive payload to the legacy `budget_tokens` shape that Bedrock rejects. + +ARNs only need the override when the suffix is opaque. An ARN that embeds the model family, +for example `...:inference-profile/us.anthropic.claude-sonnet-5`, normalises to a string the +adaptive regex already matches. + #### Using a Custom VPC Endpoint (PrivateLink) To route Bedrock traffic through a VPC interface endpoint instead of the public `bedrock-runtime` endpoint, set `AWS_BEDROCK_RUNTIME_ENDPOINT` either as an environment variable or in `[aws]`: @@ -508,6 +594,8 @@ api_base = "https://adb-xxxx.azuredatabricks.net/serving-endpoints" # your works The model name after the `databricks/` prefix is the name of your serving endpoint. See LiteLLM's [Databricks provider docs](https://docs.litellm.ai/docs/providers/databricks) for details. +The configured PAT and endpoint are captured per handler. When both `DATABRICKS_CLIENT_ID` and `DATABRICKS_CLIENT_SECRET` are set, LiteLLM performs an OAuth M2M exchange even if a PAT is supplied. An exchange failure can abort the request; after a successful exchange, the supplied PAT replaces the OAuth token in the final Authorization header. Without OAuth M2M credentials or a PAT, optional Databricks SDK authentication remains available. These deployment-owned credentials and profile selection are not request-isolated. Do not change those sources between tenants in a shared process; use request-specific PATs with OAuth M2M credentials unset, or separate processes for distinct workload identities. + ### Openrouter To use model from Openrouter, for example, set: @@ -541,7 +629,7 @@ for the [Auto](https://openrouter.ai/docs/guides/routing/routers/auto-router), #### Openrouter provider routing, reasoning and output cap -For `openrouter/...` models you can optionally restrict which upstream providers Openrouter uses, control reasoning, and cap the completion length. All keys live in the `[openrouter]` section of `configuration.toml`. Models listed in [`SUPPORT_REASONING_EFFORT_MODELS`](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/algo/__init__.py) inherit `config.reasoning_effort` unless an Openrouter-specific effort or token budget is set. +For `openrouter/...` models you can optionally restrict which upstream providers Openrouter uses, control reasoning, and cap the completion length. All keys live in the `[openrouter]` section of `configuration.toml`. Reasoning-capable models are those litellm's bundled reasoning metadata flags over the model id and its provider-prefixed/`xai/`-forms, the maintained Grok registry, or `config.additional_reasoning_effort_models`; they inherit `config.reasoning_effort` unless an Openrouter-specific effort or token budget is set. ```toml [openrouter] @@ -554,7 +642,7 @@ For `openrouter/...` models you can optionally restrict which upstream providers # max_tokens = 16000 # hard cap on completion tokens for the request ``` -`provider_only` and `reasoning_effort = "none"` are useful to pin a specific provider and to bound the cost of reasoning models. Because Openrouter treats effort and token budgets as mutually exclusive, an explicit Openrouter-specific `"none"` keeps reasoning disabled when the model supports disabling it. Grok 4.5/4.6 clamp `"none"` before precedence is applied, so a positive budget wins there; otherwise a positive `reasoning_max_tokens` value takes precedence over the global effort and other Openrouter-specific values. Invalid Openrouter-specific effort values are warned about and treated as unset, so registered reasoning models fall back to `config.reasoning_effort`. Openrouter normalizes `"max"` to `"xhigh"` in this path to match LiteLLM 1.98.0. Supported effort values vary by model, and models whose metadata marks reasoning as mandatory reject `"none"`. For Anthropic models using a reasoning budget, set the effective output `max_tokens` higher than `reasoning_max_tokens` so the final answer has output headroom. See the Openrouter [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection) and [reasoning tokens](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens) docs. +`provider_only` and `reasoning_effort = "none"` are useful to pin a specific provider and to bound the cost of reasoning models. Because Openrouter treats effort and token budgets as mutually exclusive, an explicit Openrouter-specific `"none"` keeps reasoning disabled when the model supports disabling it. Grok 4.5/4.6 clamp `"none"` before precedence is applied, so a positive budget wins there; otherwise a positive `reasoning_max_tokens` value takes precedence over the global effort and other Openrouter-specific values. Invalid Openrouter-specific effort values are warned about and treated as unset, so registered reasoning models fall back to `config.reasoning_effort`. Openrouter normalizes `"max"` to `"xhigh"` in this path for LiteLLM/OpenRouter compatibility. Supported effort values vary by model, and models whose metadata marks reasoning as mandatory reject `"none"`. For Anthropic models using a reasoning budget, set the effective output `max_tokens` higher than `reasoning_max_tokens` so the final answer has output headroom. See the Openrouter [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection) and [reasoning tokens](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens) docs. ### OrcaRouter @@ -582,7 +670,7 @@ OPENAI__KEY=... (you can obtain an OrcaRouter API key from [here](https://www.orcarouter.ai/register)) -Keep the `openai/` prefix on the model name, whatever OrcaRouter model ID you use (`openai/anthropic/claude-fable-5`, `openai/auto`, ...): the prefix routes the request through litellm's OpenAI-compatible path. A prefixed name is not in the `MAX_TOKENS` table [here](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/algo/__init__.py), so you also have to set `custom_model_max_tokens`. OrcaRouter governs routing and guardrails itself, but `config.reasoning_effort` still reaches it: PR-Agent matches the last segment of the model ID against `SUPPORT_REASONING_EFFORT_MODELS`, so an ID such as `openai/google/gemini-2.5-pro` or `openai/o3` sends the configured effort (default `"medium"`) even with nothing set. The example IDs above are not in that list and are unaffected. +Keep the `openai/` prefix on the model name, whatever OrcaRouter model ID you use (`openai/anthropic/claude-fable-5`, `openai/auto`, ...): the prefix routes the request through litellm's OpenAI-compatible path. A prefixed name is not in the `MAX_TOKENS` table [here](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/algo/__init__.py), so you also have to set `custom_model_max_tokens`. OrcaRouter governs routing and guardrails itself, but `config.reasoning_effort` still reaches it: PR-Agent probes the suffixed model ID against litellm's bundled reasoning metadata (or `config.additional_reasoning_effort_models`), so an ID such as `openai/google/gemini-2.5-pro` or `openai/o3` sends the configured effort (default `"medium"`) even with nothing set. The example IDs above are not flagged as reasoning-capable and are unaffected. ### Neon AI Gateway @@ -615,6 +703,57 @@ Create the credential per branch in the [Neon Console](https://console.neon.tech !!! note "Chat completions only" Some model IDs in Neon's catalog are served through the OpenAI Responses API, which Neon exposes under `/openai/v1` instead of `/v1`. The configuration above points at the chat-completions endpoint, so it cannot reach those models. Neon also documents a few models that return `message.content` as an array of typed blocks rather than a string, and PR-Agent reads the reply as a string. +### GitHub Copilot + +Models under an active GitHub Copilot subscription are available through litellm's `github_copilot` provider, which authenticates as your GitHub identity rather than with a dedicated API key: + +```toml +[config] +model = "github_copilot/gpt-4o" +fallback_models = ["github_copilot/gpt-4.1"] +``` + +The GitHub identity behind the model needs an active Copilot subscription. The token budget for a Copilot model is resolved automatically from litellm's model metadata (verified against the pinned litellm 1.101.0), so `custom_model_max_tokens` is not required. However, `get_max_tokens` clamps the effective window to `config.max_model_tokens`, which defaults to 32000. To use the full context window of the model (e.g., 64000 for gpt-4o, 128000 for gpt-4.1), raise `config.max_model_tokens` accordingly. + +Authentication uses the [GitHub Copilot provider](https://docs.litellm.ai/docs/providers/github_copilot) flow: + +1. litellm first looks for a pre-seeded GitHub access token in `access-token` under `GITHUB_COPILOT_TOKEN_DIR` (default `~/.config/litellm/github_copilot`; the filename is overridable with `GITHUB_COPILOT_ACCESS_TOKEN_FILE`). In a CI runner, write that token before the job runs - for example, mount a secret into the directory or point the variable at a mounted secret directory - and the flow never becomes interactive. +2. Only when the file is missing or empty does litellm fall back to the interactive device-code flow (`POST https://github.com/login/device/code`, up to three attempts), which does not suit unattended runners. +3. The Copilot API key (`api-key.json` in the same directory) is refreshed automatically against `https://api.github.com/copilot_internal/v2/token`, using the pre-seeded access token. + +Whether Copilot's terms permit this programmatic use is a question for GitHub rather than a guarantee this project can make, so confirm before relying on the route. + +### Atlas Cloud + +[Atlas Cloud](https://www.atlascloud.ai) is an OpenAI-compatible inference platform. It needs no provider-specific code in PR-Agent: the `openai/` prefix routes the request to Atlas's base URL through litellm's OpenAI-compatible path, the same way [OrcaRouter](#orcarouter) and the [Neon AI Gateway](#neon-ai-gateway) are handled. + +To use a model served by Atlas Cloud, set: + +```toml +[config] # in configuration.toml +model = "openai/deepseek-ai/deepseek-v4-pro" +fallback_models = ["openai/deepseek-ai/deepseek-v4-pro"] +custom_model_max_tokens = 1048000 # the context window Atlas publishes for the model + +[openai] # in .secrets.toml +api_base = "https://api.atlascloud.ai/v1" +key = "..." # your Atlas Cloud api key +``` + +or use the environment variables (make sure to use double underscores `__`): + +```bash +OPENAI__API_BASE=https://api.atlascloud.ai/v1 +OPENAI__KEY=... +``` + +(you can obtain an Atlas Cloud API key from the [console](https://www.atlascloud.ai/console)) + +Keep the `openai/` prefix on the model name, whichever Atlas model ID you use (`openai/deepseek-ai/deepseek-v4-pro`, `openai/zai-org/glm-5`, `openai/moonshotai/kimi-k2.6`, ...): the prefix routes the request through litellm's OpenAI-compatible path. A prefixed name is not in the `MAX_TOKENS` table [here](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/algo/__init__.py), so you also have to set `custom_model_max_tokens`. Take the value from Atlas's [model catalog](https://www.atlascloud.ai/models). + +!!! note "Reasoning models need output headroom" + Several Atlas models are reasoning models that spend completion tokens on a hidden chain of thought before writing the answer. `deepseek-ai/deepseek-v4-pro` with `max_tokens = 16` returns `finish_reason = "length"` and an **empty** `message.content` — all 16 completion tokens were reasoning tokens. If a tool comes back blank, raise the output budget rather than assuming the request failed. Non-reasoning IDs such as `deepseek-ai/DeepSeek-V3.1` are unaffected. + ### Custom models If the relevant model doesn't appear [here](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/algo/__init__.py), you can still use it as a custom model: @@ -650,6 +789,8 @@ reasoning_effort = "medium" # "none", "minimal", "low", "medium", "high", "xhigh With the OpenAI models that support reasoning effort (eg: gpt-5.6-terra), you can specify its reasoning effort via `config` section. The default value is `medium`. You can change it to any supported value based on your usage. Available values depend on the model and provider. +For a model served through an OpenAI-compatible endpoint that litellm does not recognize as reasoning-capable, add its ID to `config.additional_reasoning_effort_models`. For known models support is decided by litellm's bundled reasoning metadata plus the maintained Grok registry (Grok ids resolve through their `xai/` prefix) with Claude models left out of the metadata path (their reasoning comes from the dedicated extended/adaptive thinking settings; an explicit entry in the list above still applies to them). Config IDs match exactly or through any provider prefix (e.g. `"deepseek-v4-flash-0731"` matches `"openai/deepseek-v4-flash-0731"`). When LiteLLM does not recognize the model, PR-Agent sets `allowed_openai_params = ["reasoning_effort"]` so the parameter reaches the endpoint. Note the default `"medium"` may be rejected by providers that accept a different subset (e.g. `"none"/"low"/"high"/"max"`); adding a custom model ID surfaces that provider-side error instead of silently dropping the setting. + To use [GPT-6 Astra](https://developers.openai.com/api/docs/models/gpt-6-astra): ```toml @@ -693,6 +834,11 @@ built-in defaults. for it and logs a warning rather than sending a request the provider would reject — use `enable_claude_adaptive_thinking` for those models instead. +Both thinking gates only fire when the model id itself is recognizable: an opaque id such as a +Bedrock application inference profile ARN matches neither gate, and PR-Agent logs a warning +instead of silently sending nothing. See [Claude 5 thinking with an application inference +profile ARN](#claude-5-thinking-with-an-application-inference-profile-arn). + ## Output token limit ```toml diff --git a/docs/docs/usage-guide/configuration_options.md b/docs/docs/usage-guide/configuration_options.md index 7d0521b785..83e2eafa09 100644 --- a/docs/docs/usage-guide/configuration_options.md +++ b/docs/docs/usage-guide/configuration_options.md @@ -8,7 +8,7 @@ There are three main ways to set persistent configurations: In terms of precedence, local configurations will override global configurations, and global configurations will override an external configuration URL. -For a list of all possible configurations, see the [configuration options](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) page. +For a list of all possible configurations, see the [configuration options](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) page, or the rendered [Configuration Reference](./configuration_reference.md) which lists every option grouped by section. In addition to general configuration options, each tool has its own configurations. For example, the `review` tool will use parameters from the [pr_reviewer](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) section in the configuration file. !!! tip "Tip1: Edit only what you need" @@ -39,7 +39,7 @@ Then you can give a list of extra instructions to the `review` tool. ### Loading the local configuration from a non-default branch -`Platforms supported: GitHub` +`Platforms supported: GitHub, GitLab` By default, the local `.pr_agent.toml` is read from the repo's **default branch**. When running PR-Agent from the CLI (or any wrapper that exposes its arguments), you can point it at a different branch — for example to test configuration changes from a feature branch before merging them: @@ -59,8 +59,8 @@ If `.pr_agent.toml` cannot be loaded from the requested branch (e.g. the branch **Never set the config branch from untrusted or PR-derived input** (e.g. `--config-branch=$GITHUB_HEAD_REF` / `${{ github.head_ref }}` in CI). Doing so lets anyone who can push a branch to the repository supply their own `.pr_agent.toml` and control the review — for example pointing `model`/the API base at an attacker endpoint to exfiltrate the diff, injecting `extra_instructions`, or enabling auto-approval of their own PR. Always pin the config branch to a fixed, maintainer-controlled branch. -!!! note "GitHub only" - Branch selection is currently implemented for GitHub. On all other platforms the `--config-branch` flag and `PR_AGENT_CONFIG_BRANCH` variable are ignored, and the local `.pr_agent.toml` is always read from the default branch. +!!! note "GitHub and GitLab only" + Branch selection is currently implemented for GitHub and GitLab. On all other platforms the `--config-branch` flag and `PR_AGENT_CONFIG_BRANCH` variable are ignored, and the local `.pr_agent.toml` is always read from the default branch. ## Global configuration file @@ -89,11 +89,11 @@ Loading the global settings file is controlled by the `use_global_settings_file` use_global_settings_file = false ``` -For example, in the GitHub organization `qodo-ai`: +For example, in a GitHub organization named `my-org`: -- The file [`https://github.com/the-pr-agent/pr-agent-settings/.pr_agent.toml`](https://github.com/the-pr-agent/pr-agent-settings/blob/main/.pr_agent.toml) serves as a global configuration file for all the repos in the GitHub organization `qodo-ai`. +- The file `my-org/pr-agent-settings/.pr_agent.toml` (read from that repository's default branch) serves as a global configuration file for all the repos in the organization. -- The repo [`https://github.com/the-pr-agent/pr-agent`](https://github.com/the-pr-agent/pr-agent/blob/main/.pr_agent.toml) inherits the global configuration file from `pr-agent-settings`. +- A repository such as `my-org/my-repo` inherits that global configuration file, and may override any of its values in its own `.pr_agent.toml`. ## Project/Group level configuration file @@ -105,25 +105,6 @@ The configuration file in this repository will apply to all repositories directl !!! note "Note" For GitLab, in case of a repository nested in several sub groups, the lookup for a pr-agent-settings repo will be only on one level above such repository. - -## Organization level configuration file - -`Relevant platforms: Bitbucket Data Center` - -Create a dedicated project to hold a global configuration file that affects all repositories across all projects in your organization. - -**Setting up organization-level global configuration:** - -1. Create a new project with both the name and key: PR_AGENT_SETTINGS. -2. Inside the PR_AGENT_SETTINGS project, create a repository named pr-agent-settings. -3. In this repository, add a `.pr_agent.toml` configuration file—structured similarly to the global configuration file described above. -4. Optionally, you can add organizational-level [global best practices](../tools/improve.md#global-hierarchical-best-practices). - -Repositories across your entire Bitbucket organization will inherit the configuration from this file. - -!!! note "Note" - If both organization-level and project-level global settings are defined, the project-level settings will take precedence over the organization-level configuration. Additionally, parameters from a repository’s local .pr_agent.toml file will always override both global settings. - ## External configuration URL `Platforms supported: GitHub, GitLab, Bitbucket, Azure DevOps` diff --git a/docs/docs/usage-guide/configuration_reference.md b/docs/docs/usage-guide/configuration_reference.md new file mode 100644 index 0000000000..87e2d73972 --- /dev/null +++ b/docs/docs/usage-guide/configuration_reference.md @@ -0,0 +1,555 @@ +# Configuration Reference + +> This page is **auto-generated** and should not be edited by hand. +> Regenerate it from the [TOML source](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) with: +> +> ```bash +> python scripts/generate_config_reference.py +> ``` + +Every configuration option PR-Agent supports, grouped by section. The [configuration.toml](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) +file is the single source of truth for defaults and inline comments; this page renders the same +list for easy searching and linking. + +Rows with an empty **Description** are keys whose TOML entry carries no explanatory comment yet. +They are listed deliberately rather than hidden, so the gaps double as the documentation +to-do list. + +## `[config]` + +**models** + +| Key | Default | Description | +| --- | --- | --- | +| `model` | "gpt-5.6" | | +| `fallback_models` | ["gpt-5.6-terra"] | | +**CLI** + +| Key | Default | Description | +| --- | --- | --- | +| `git_provider` | "github" | | +| `publish_output` | true | | +| `publish_output_progress` | true | | +| `progress_gif_url` | "" | optional, override for the progress loading gif url (example: 'https://.../spinner.gif'). | +| `progress_gif_width` | 48 | optional, width (in px) of the progress loading gif. | +| `verbosity_level` | 0 | 0,1,2 | +| `use_extra_bad_extensions` | false | | +**Log** + +| Key | Default | Description | +| --- | --- | --- | +| `log_level` | "DEBUG" | | +**Configurations** + +| Key | Default | Description | +| --- | --- | --- | +| `use_repo_settings_file` | true | | +| `use_global_settings_file` | true | | +| `enable_per_directory_settings` | false | when true, merge per-directory .pr_agent.toml files found by walking up from the PR's changed files (monorepo support). Adds bounded recursive tree discovery per MR; per-directory files may only override non-critical sections (see REPO_PER_DIRECTORY_OVERRIDABLE_SECTIONS). Nearest (deepest) directory wins on shared keys; equal-depth siblings resolve to the lexicographically-last path; when more files match than the per_directory_settings_max_files cap, shallower files are applied first and a partially capped depth keeps its later-path (winning) siblings; any overlap is logged as a warning. | +| `per_directory_settings_max_files` | 20 | hard ceiling on the number of per-directory .pr_agent.toml files applied per MR (deeper configs beyond the cap are skipped with a warning) | +| `per_directory_settings_max_tree_pages` | 10 | maximum GitLab recursive-tree pages (100 entries each); skip nested settings if discovery is incomplete. Root/host-controlled, independent of the settings-file cap. | +| `extra_config_url` | "" | optional URL or path to an additional .pr_agent.toml merged before the repo-local config; also settable via --extra_config_url or PR_AGENT_EXTRA_CONFIG_URL. See docs/docs/usage-guide/configuration_options.md#external-configuration-url. | +| `disable_auto_feedback` | false | | +| `enable_auto_approval` | false | when true, /review may auto-approve a PR via auto_approve_logic(); that caller is currently commented out | +| `ai_timeout` | 120 | 2 minutes | +| `retry_same_model_on_timeout` | true | when false, a timed-out call is not retried on the same model and moves on to fallback_models | +| `skip_keys` | [] | | +| `custom_reasoning_model` | false | when true, disables system messages and temperature controls for models that don't support chat-style inputs | +| `response_language` | "en-US" | Language locales code for PR responses in ISO 3166 and ISO 639 format (e.g., "en-US", "it-IT", "zh-CN", ...) | +| `repo_context_files` | ["AGENTS.md"] | Repository-relative files (e.g. AGENTS.md, CLAUDE.md) to include as AI prompt context; set to [] to disable local context. A structured entry {"repo_id" = ..., "file_path" = ...} selects a sibling default-branch file from the same namespace/owner; repo_id must be in the host-issued repo_context_sibling_repos allowlist below. Reads use the sibling default branch and share repo_context_max_lines; repository settings may select entries, comment arguments cannot override this key | +| `repo_context_from_default_branch` | true | Read repo context files from the repository default branch (trusts only default-branch content). Set to false to read from the PR target branch instead. | +| `repo_context_max_lines` | 500 | Maximum total rendered lines for repo context, including wrapper tags | +| `repo_context_sibling_repos` | [] | Host-only list of approved sibling repository identifiers (GitHub owner/repo, GitLab group/project or numeric ID strings) that repo_context_files sibling entries may select. Empty disables sibling reads. Approve only repositories whose content may be disclosed in consuming PRs, because the actor check bounds who triggers a read, not who chose the target or where the output lands. Repository settings and comment arguments cannot change this list. Canonical identities and owning namespaces are checked after resolution | +| `repo_context_max_sibling_files` | 5 | Maximum number of sibling-repository files fetched per repo-context build. The fetch count is bounded separately from repo_context_max_lines so selected sibling files cannot trigger an unbounded number of cross-repository calls; sibling files still compete for the repo_context_max_lines budget. Host-only (cannot be raised by a repository's .pr_agent.toml or a comment command) and clamped to a hard ceiling of 20 fetches per build. | +**token limits** + +| Key | Default | Description | +| --- | --- | --- | +| `max_description_tokens` | 500 | | +| `max_commits_tokens` | 500 | | +| `max_model_tokens` | 32000 | Limits the maximum number of tokens that can be used by any model, regardless of the model's default capabilities. | +| `custom_model_max_tokens` | -1 | for models not in the default list | +| `max_output_tokens` | 0 | 0 = unset (the provider's own default applies) | +| `model_token_count_estimate_factor` | 0.3 | factor to increase the token count estimate, in order to reduce likelihood of model failure due to too many tokens - applicable only when requesting an accurate estimate. | +| `image_input_token_allowance` | 4096 | reserve tokens per image when provider counting omits or underestimates image cost | +**patch extension logic** + +| Key | Default | Description | +| --- | --- | --- | +| `patch_extension_skip_types` | [".md", ".txt"] | | +| `allow_dynamic_context` | true | | +| `max_extra_lines_before_dynamic_context` | 10 | will try to include up to 10 extra lines before the hunk in the patch, until we reach an enclosing function or class | +| `patch_extra_lines_before` | 5 | Number of extra lines (+3 default ones) to include before each hunk in the patch | +| `patch_extra_lines_after` | 1 | Number of extra lines (+3 default ones) to include after each hunk in the patch | +| `secret_provider` | "" | "" (disabled), "google_cloud_storage", or "aws_secrets_manager" for secure secret management | +| `cli_mode` | false | | +| `output_relevant_configurations` | false | | +| `output_run_details` | false | if true, append an agent run details section (model, tokens, time cost, AI calls) to generated PR comments | +| `output_run_cost` | false | if true, collect estimated LiteLLM API cost and include it inside the enabled run details section | +| `large_patch_policy` | "clip" | "clip", "skip" | +| `duplicate_prompt_examples` | false | | +| `persistent_inline_comments` | false | Enable persistent inline comments (issue #2037) to fingerprint each inline comment and embed a provider-compatible marker, then skip re-posting suggestions already present on the PR/MR across runs. | +**seed** + +| Key | Default | Description | +| --- | --- | --- | +| `seed` | -1 | set positive value to fix the seed (and ensure temperature=0) | +| `temperature` | 0.2 | | +**ignore logic** + +| Key | Default | Description | +| --- | --- | --- | +| `ignore_pr_title` | ["^\\[Auto\\]", "^Auto"] | a list of regular expressions to match against the PR title to ignore the PR agent | +| `ignore_pr_target_branches` | [] | a list of regular expressions of target branches to ignore from PR agent when an PR is created | +| `ignore_pr_source_branches` | [] | a list of regular expressions of source branches to ignore from PR agent when an PR is created | +| `ignore_pr_labels` | [] | labels to ignore from PR agent when an PR is created | +| `ignore_pr_authors` | [] | authors to ignore from PR agent when an PR is created | +| `reaction_on_start` | "eyes" | added before the command runs | +| `reaction_on_success` | "" | added when the command finished successfully | +| `reaction_on_failure` | "" | added when the command failed | +| `ignore_repositories` | [] | a list of regular expressions of repository full names (e.g. "org/repo") to ignore from PR agent processing | +| `ignore_language_framework` | [] | a list of code-generation languages or frameworks (e.g. 'protobuf', 'go_gen') whose auto-generated source files will be excluded from analysis | +| `bot_user_indicators` | ["codium", "bot_", "bot-", "_bot", "-bot"] | Substring indicators used to skip bot users on webhook events. Currently consumed by the GitLab webhook (`is_bot_user`); other providers may adopt this list in future. The match is case-insensitive against the sender's display name. Overriding this setting REPLACES the default list — include the entries below in your override if you want to keep them (e.g. `["codium", "bot_", "bot-", "_bot", "-bot", "renovate"]`). | +| `restricted_mode` | false | when true, skip operations that require elevated permissions (e.g. pushing code to the repository) | +| `is_auto_command` | false | will be auto-set to true if the command is triggered by an automation | +| `propagate_tool_errors` | false | when true, a tool re-raises instead of swallowing an internal error, so a caller can tell a failed run from an empty one | +| `enable_ai_metadata` | false | will enable adding ai metadata | +| `add_user_to_requests` | false | send the current command and PR URL in the OpenAI-compatible "user" request field, for provider-side attribution of requests (e.g. OpenRouter "external_user") | +| `reasoning_effort` | "medium" | "none", "minimal", "low", "medium", "high", "xhigh", "max" | +| `additional_reasoning_effort_models` | [] | Optional: additional model IDs that accept config.reasoning_effort. Reasoning support is otherwise decided by litellm's bundled model metadata (and the maintained Grok registry), so add an ID here only when litellm does not know the model (custom OpenAI-compatible endpoints). Model IDs match exactly or through any provider prefix (e.g. "deepseek-v4-flash-0731" matches "openai/deepseek-v4-flash-0731"). LiteLLM whitelists reasoning_effort through allowed_openai_params for OpenAI-compatible models it does not recognize, so the parameter reaches the endpoint. The default "medium" may be rejected by providers that accept a different subset (e.g. "none"/"low"/"high"/"max"); adding a custom model id now surfaces a provider-side error instead of the previous silent drop. | +**extended thinking for Claude reasoning models** + +| Key | Default | Description | +| --- | --- | --- | +| `enable_claude_extended_thinking` | false | Set to true to enable extended thinking feature | +| `extended_thinking_budget_tokens` | 2048 | | +| `extended_thinking_max_output_tokens` | 4096 | | +| `enable_claude_adaptive_thinking` | false | Adaptive thinking for Claude Opus 4.7/4.8 and Claude 5 models. When enabled, these models receive thinking={"type": "adaptive"} and an output_config effort. Do not add adaptive-only models to claude_extended_thinking_models_override; when both features are enabled, adaptive thinking takes precedence. | +| `claude_adaptive_thinking_models_override` | [] | Optional: list model ids to additionally treat as adaptive-only Claude models. Add opaque Bedrock application inference profile ARNs here while keeping built-in detection for named models. When adaptive thinking is enabled, use the exact request model ids so LiteLLM keeps the adaptive payload instead of converting it to the legacy budget_tokens shape. | +| `claude_extended_thinking_models_override` | [] | Optional: override the built-in list of Claude models that receive the extended-thinking payload. When non-empty, this list fully replaces the built-in defaults (see CLAUDE_EXTENDED_THINKING_MODELS in pr_agent/algo/__init__.py). Leave empty to use the defaults. | +| `extract_issue_from_branch` | true | Extract issue number from PR source branch name (e.g. feature/1-auth-google -> issue #1). When true, branch-derived issue URLs are merged with tickets from the PR description for compliance. Set to false to restore description-only behaviour. Note: Branch-name extraction is GitHub-only for now; other providers planned for later. | +| `branch_issue_regex` | "" | Optional: custom regex with exactly one capturing group for the issue number (validated at runtime; falls back to default if missing). If empty, uses default pattern: first 1-6 digits at start of branch or after a slash, followed by hyphen or end (e.g. feature/1-test, 123-fix). GitHub only; other providers planned for later. | +| `description_issue_regex` | "" | Configure a regex replacing bare #N references, with exactly one capturing group for an ASCII issue number. Leave empty for default matching (up to six digits); fall back with a warning on invalid patterns. Set the custom digit limit in the pattern; use only integer-parseable captures. Keep full URLs and owner/repo#N references. Use TOML literal quotes to preserve backslashes, e.g. description_issue_regex = '(?i)(?:fixes\|closes\|resolves)\s+#(\d+)' | + + +## `[pr_reviewer]` — /review + +**enable/disable features** + +| Key | Default | Description | +| --- | --- | --- | +| `require_score_review` | false | | +| `require_tests_review` | true | | +| `require_estimate_effort_to_review` | true | | +| `require_can_be_split_review` | false | | +| `require_security_review` | true | | +| `require_estimate_contribution_time_cost` | false | | +| `require_todo_scan` | false | | +| `require_ticket_analysis_review` | true | | +| `require_risk_assessment` | false | ask the model for an overall risk level (low/medium/high) | +| `require_merge_recommendation` | false | ask the model for a merge recommendation (safe_to_merge/merge_with_caution/changes_required) | +| `require_priority_files` | false | ask the model which files a human should inspect first | +**general options** + +| Key | Default | Description | +| --- | --- | --- | +| `publish_output_no_suggestions` | true | Set to "false" if you only need the reviewer's remarks (not labels, not "security audit", etc.) and want to avoid noisy "No major issues detected" comments. | +| `publish_error_details` | false | Publish a deterministic, sanitized failure reason in manual review comments. No AI call is used. | +| `persistent_comment` | true | | +| `review_heading` | "PR Reviewer Guide" | Visible base heading for full and incremental review comments. Identity is tracked separately. | +| `persistent_finding_state` | true | Persist review finding state across complete review runs. | +| `inline_key_issues` | false | | +| `extra_instructions` | "" | | +| `num_max_findings` | 3 | | +| `final_update_message` | true | | +**review labels** + +| Key | Default | Description | +| --- | --- | --- | +| `enable_review_labels_security` | true | | +| `enable_review_labels_effort` | true | | +**specific configurations for incremental review (/review -i)** + +| Key | Default | Description | +| --- | --- | --- | +| `require_all_thresholds_for_incremental_review` | false | | +| `minimal_commits_for_incremental_review` | 0 | | +| `minimal_minutes_for_incremental_review` | 0 | | +| `enable_intro_text` | true | | +| `enable_help_text` | false | Determines whether to include help text in the PR review. | +| `enable_review_coverage_footer` | true | | +| `enable_large_pr_chunking` | false | large-diff chunking (opt-in). When the token budget leaves files out of the review, split the diff into chunks, review each chunk, and merge the per-chunk results into one review. | +| `max_number_of_calls` | 3 | maximum number of chunk review calls, used only when enable_large_pr_chunking is true | + + +## `[pr_description]` — /describe + +| Key | Default | Description | +| --- | --- | --- | +| `publish_labels` | false | | +| `add_original_user_description` | true | | +| `generate_ai_title` | false | | +| `extra_instructions` | "" | | +| `enable_pr_type` | true | | +| `enable_pr_description` | true | adds a section with an AI-generated summary of the PR changes | +| `final_update_message` | true | | +| `enable_help_text` | false | | +| `enable_help_comment` | false | | +| `enable_pr_diagram` | true | adds a section with a diagram of the PR changes | +| `pr_diagram_direction` | "adaptive" | 'adaptive', 'LR', 'TD'. 'adaptive' picks the direction from the shape of the diagram | +| `pr_diagram_direction_threshold` | 5 | with 'adaptive', a chain longer than this many nodes is drawn top-down | +**describe as comment** + +| Key | Default | Description | +| --- | --- | --- | +| `publish_description_as_comment` | false | | +| `publish_description_as_comment_persistent` | true | | +**changes walkthrough section** + +| Key | Default | Description | +| --- | --- | --- | +| `enable_semantic_files_types` | true | | +| `collapsible_file_list` | "adaptive" | true, false, 'adaptive' | +| `collapsible_file_list_threshold` | 6 | | +| `file_table_collapsible_open_by_default` | false | | +**markers** + +| Key | Default | Description | +| --- | --- | --- | +| `use_description_markers` | false | | +| `enable_large_pr_handling` | true | | +| `include_generated_by_header` | true | | +| `max_ai_calls` | 4 | | +| `async_ai_calls` | true | | + + +## `[pr_questions]` — /ask + +| Key | Default | Description | +| --- | --- | --- | +| `enable_help_text` | false | | +| `use_conversation_history` | true | | +| `ask_heading` | "Ask" | Set the plain-text heading for top-level /ask answers; provider-specific presentation is added automatically. | +| `resolve_threads` | false | Enable to let /ask_line resolve the review thread when the LLM judges the issue addressed. Note: also resolves threads started by human reviewers. | +| `extra_instructions` | "" | | + + +## `[pr_code_suggestions]` — /improve + +| Key | Default | Description | +| --- | --- | --- | +| `commitable_code_suggestions` | false | | +| `dual_publishing_score_threshold` | -1 | -1 to disable, [0-10] to set the threshold (>=) for publishing a code suggestion both in a table and as committable | +| `focus_only_on_problems` | true | | +| `extra_instructions` | "" | | +| `suggestions_heading` | "PR Code Suggestions" | Visible base heading for summary-table /improve comments. Identity is tracked separately. | +| `enable_help_text` | false | | +| `enable_chat_text` | false | | +| `persistent_comment` | true | | +| `max_history_len` | 4 | | +| `publish_output_no_suggestions` | true | | +| `enable_suggestions_coverage_footer` | true | show when failed analysis chunks make the suggestions incomplete | +**suggestions scoring** + +| Key | Default | Description | +| --- | --- | --- | +| `suggestions_score_threshold` | 0 | [0-10]\| recommend not to set this value above 8, since above it may clip highly relevant suggestions | +| `new_score_mechanism` | true | | +| `new_score_mechanism_th_high` | 9 | | +| `new_score_mechanism_th_medium` | 7 | | +**params for '/improve --extended' mode** + +| Key | Default | Description | +| --- | --- | --- | +| `num_code_suggestions_per_chunk` | 3 | | +| `max_suggestions_per_file` | 0 | Maximum suggestions retained per file after all chunks are merged; 0 disables the cap. | +| `max_number_of_calls` | 3 | | +| `parallel_calls` | true | | +| `decouple_hunks` | false | | +**self-review checkbox** + +| Key | Default | Description | +| --- | --- | --- | +| `demand_code_suggestions_self_review` | false | add a checkbox for the author to self-review the code suggestions | +| `code_suggestions_self_review_text` | "**Author self-review**: I have reviewed the PR code suggestions, and addressed the relevant ones." | | +| `approve_pr_on_self_review` | false | if true, the PR will be auto-approved after the author clicks on the self-review checkbox | +| `fold_suggestions_on_self_review` | true | if true, the code suggestions will be folded after the author clicks on the self-review checkbox | + + +## `[pr_add_docs]` — /add_docs + +| Key | Default | Description | +| --- | --- | --- | +| `extra_instructions` | "" | | +| `docs_style` | "Sphinx" | "Google Style with Args, Returns, Attributes...etc", "Numpy Style", "Sphinx Style", "PEP257", "reStructuredText" | + + +## `[pr_update_changelog]` — /update_changelog + +| Key | Default | Description | +| --- | --- | --- | +| `push_changelog_changes` | false | | +| `extra_instructions` | "" | | +| `add_pr_link` | true | | +| `skip_ci_on_push` | true | | + + +## `[pr_config]` — /config + +_This section only documents commented-out examples; see the [TOML source](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) for details._ + +## `[pr_help_docs]` + +| Key | Default | Description | +| --- | --- | --- | +| `repo_url` | "" | If not overwritten, will use the repo from where the context came from (issue or PR) | +| `repo_default_branch` | "main" | | +| `docs_path` | "docs" | | +| `exclude_root_readme` | false | | +| `supported_doc_exts` | [".md", ".mdx", ".rst"] | | +| `enable_help_text` | false | | + + +## `[github]` + +| Key | Default | Description | +| --- | --- | --- | +| `deployment_type` | "user" | The type of deployment to create. Valid values are 'app' or 'user'. | +| `ratelimit_retries` | 5 | | +| `seconds_between_requests` | 0 | seconds between API requests; 0 = no pacing (1.59 behaviour) | +| `seconds_between_writes` | 0 | seconds between write calls; 0 = no pacing (1.59 behaviour) | +| `api_retries` | 0 | max retries per request with backoff; 0 = no retries (1.59 behaviour) | +| `polling_request_timeout` | 10 | total seconds for comment-history fallback; positive values capped at 60 | +| `base_url` | "https://api.github.com" | | +| `try_fix_invalid_inline_comments` | true | | +| `ignore_bot_pr` | true | | +| `publish_as_check_run` | false | when true, publish review/description/improve output as GitHub Checks instead of PR comments | + + +## `[github_action_config]` + +_This section only documents commented-out examples; see the [TOML source](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) for details._ + +## `[github_app]` + +| Key | Default | Description | +| --- | --- | --- | +| `override_deployment_type` | true | these toggles allows running the github app from custom deployments | +**settings for "pull_request" event** + +| Key | Default | Description | +| --- | --- | --- | +| `handle_pr_actions` | ["opened", "reopened", "ready_for_review"] | | +| `feedback_on_draft_pr` | false | | +| `review_states` | ["changes_requested"] | A submitted GitHub review can optionally trigger these commands. The empty default preserves current behavior. | +| `review_author_types` | ["User"] | | +| `review_commands` | [] | | +| `webhook_delivery_deduplication` | false | Opt in to in-memory deduplication by X-GitHub-Delivery within each worker process. Active work stays protected; completed IDs expire after the deployment's push_trigger_pending_tasks_ttl (read at startup, 300 seconds by default). Failed or cancelled work can retry immediately. Repeated deliveries do not extend the TTL. Manual redeliveries are also suppressed during this window. State is cleared on restart and is not shared across workers or replicas. | +| `handle_push_trigger` | false | settings for "pull_request" event with "synchronize" action - used to detect and handle push triggers for new commits | +| `push_trigger_ignore_bot_commits` | true | | +| `push_trigger_ignore_merge_commits` | true | | +| `push_trigger_pending_tasks_backlog` | true | | +| `push_trigger_pending_tasks_ttl` | 300 | | +| `push_commands` | ["/describe", "/review"] | | + + +## `[gitlab]` + +| Key | Default | Description | +| --- | --- | --- | +| `url` | "https://gitlab.com" | | +| `expand_submodule_diffs` | false | | +| `feedback_on_draft_pr` | false | | +| `publish_review_as_thread` | false | Post the /review summary as a resolvable thread (discussion) instead of a plain note. | +| `publish_improve_as_thread` | false | Post the /improve suggestions comment as a resolvable thread (discussion) instead of a plain note. | +| `publish_code_suggestions_as_review` | false | When pr_code_suggestions.commitable_code_suggestions is true, queue each suggestion as a GitLab draft note and publish them all together in one batch (like GitLab's own "start a review" flow) instead of posting each as its own live discussion - and its own notification - as soon as it's created. | +| `resolve_outdated_inline_threads` | false | Resolve the bot's own inline threads that a later push left on an outdated diff version. | +| `auto_resolve_fixed_inline_threads` | false | Resolve the bot's own inline threads whose flagged line was modified after the comment was posted - i.e. the diff between the comment's head sha and the current head sha removes/replaces that line. Unlike resolve_outdated_inline_threads this is content-based: threads on lines nobody touched (or merely shifted by unrelated insertions) stay open. | +| `handle_push_trigger` | false | | +| `push_commands` | ["/describe", "/review"] | | +| `handle_reviewer_assignment` | false | Auto-trigger commands when the bot is assigned as a reviewer on an MR | +| `reviewer_commands` | ["/review"] | | + + +## `[gitea]` + +| Key | Default | Description | +| --- | --- | --- | +| `url` | "https://gitea.com" | | +| `handle_push_trigger` | false | | +| `push_commands` | ["/describe", "/review"] | | + + +## `[bitbucket_app]` + +| Key | Default | Description | +| --- | --- | --- | +| `avoid_full_files` | false | | +| `request_timeout` | 30 | positive seconds for connection and response-read inactivity timeouts | + + +## `[local]` + +_This section only documents commented-out examples; see the [TOML source](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) for details._ + +## `[gerrit]` + +| Key | Default | Description | +| --- | --- | --- | +| `webhook_username` | "" | both are required: the gerrit webhook endpoint rejects every call until they are set | +| `webhook_password` | "" | | + + +## `[bitbucket_server]` + +| Key | Default | Description | +| --- | --- | --- | +| `url` | "" | URL to the BitBucket Server instance | + + +## `[jira]` + +| Key | Default | Description | +| --- | --- | --- | +| `jira_requirements_field` | "" | Custom field id holding acceptance criteria / requirements, mapped to the ticket "requirements" section. Instance-specific (e.g. "customfield_10127"); empty disables it. | +| `project_keys` | [] | Optional allowlist of Jira project keys, e.g. ["PROJ", "OPS"]. When non-empty, key-shaped text with another prefix ("SHA-256", "UTF-8", "ISO-8601") is dropped before any lookup, so it no longer costs an authenticated 404 each. Entries must be plain upper-case keys; a supplied list with no valid entry disables the lookup rather than widening it. Empty (default) looks up every key found. | + + +## `[litellm]` + +| Key | Default | Description | +| --- | --- | --- | +| `enable_callbacks` | false | | +| `success_callback` | [] | | +| `failure_callback` | [] | | +| `service_callback` | [] | | +| `turn_off_message_logging` | false | set true to keep prompt/response content (the whole PR diff) out of callback payloads; strongly recommended with the "otel" callback | +| `custom_llm_provider` | "" | Optional: forward a fixed custom_llm_provider to LiteLLM, so a raw hosted model id (e.g. "claude-sonnet-4-5") reaches the provider unchanged instead of being rewritten by LiteLLM's prefix inference. Empty = let LiteLLM infer the provider from the model name. | +| `force_streaming_custom_llm_provider` | "" | Force streaming when the request matches this provider AND its api_base contains one of the substrings below. Some OpenAI-compatible endpoints return a response that LiteLLM cannot normalize in non-streaming mode. Both must be set for the workaround to apply. | +| `force_streaming_api_base_substrings` | [] | | +| `callback_timeout_seconds` | 30 | max seconds to wait for pending litellm callbacks to flush before exiting | +| `cache_control_injection_points` | [] | Optional: enable Anthropic prompt caching via LiteLLM, e.g. [{location = "message", role = "system"}] (https://docs.litellm.ai/docs/tutorials/prompt_caching) | + + +## `[openrouter]` + +| Key | Default | Description | +| --- | --- | --- | +| `provider_only` | [] | restrict routing to these upstream providers only, a hard allowlist (e.g. ["z-ai"]); empty = OpenRouter default routing | +| `provider_order` | [] | preferred provider order; ignored when provider_only is set; empty = unset | +| `allow_fallbacks` | true | when provider_order is set, allow routing beyond the listed providers | +| `reasoning_effort` | "" | Invalid reasoning_effort values are warned about and treated as unset. Empty inherits config.reasoning_effort for reasoning-capable models (probed against litellm's bundled reasoning metadata or the Grok registry, or listed in additional_reasoning_effort_models). Valid values: "none", "minimal", "low", "medium", "high", "xhigh", "max". OpenRouter normalizes "max" to "xhigh" for LiteLLM/OpenRouter compatibility. Model-specific support varies; mandatory reasoning models reject "none". | +| `reasoning_max_tokens` | 0 | A positive value overrides global effort and non-none OpenRouter-specific efforts. Explicit openrouter.reasoning_effort = "none" keeps reasoning disabled, except on Grok 4.5/4.6: there "none" is clamped to the lowest supported effort first, so a positive budget wins over it. Some providers require max_tokens to be greater than the reasoning budget. | +| `max_tokens` | 0 | hard cap on completion tokens for the request; 0 = unset | + + +## `[model_routing]` — send a small pull request to a cheaper primary model (disabled by default) + +| Key | Default | Description | +| --- | --- | --- | +| `enable` | false | Rules are checked in order; the first one whose limits the pull request fits selects the primary model for the call, and config.fallback_models still apply after it. A pull request that fits no rule uses config.model. Size is measured after the [ignore] rules by diff hunks (max_hunks) and changed files (max_files), which every git provider reports and which do not depend on any model's tokenizer. Only calls that ask for the regular model are routed (/review, /improve, /generate_labels, /add_docs); tools that use model_weak are left alone. With Azure (openai.deployment_id set) a rule also needs its own deployment_id, or it is skipped. | +| `rules` | [] | e.g. [{ max_hunks = 3, model = "gpt-5.6-luna" }, { max_hunks = 15, max_files = 6, model = "gpt-5.6-terra" }] | + + +## `[otel]` + +| Key | Default | Description | +| --- | --- | --- | +| `is_enabled` | false | disabled by default; set to true to enable telemetry | +| `exporter_type` | "console" | "console", "otlp", "prometheus", or "none" | +| `service_name` | "pr-agent" | | +| `environment` | "development" | "development", "staging", "production", etc. | +| `otlp_timeout` | 3 | seconds; hard deadline per OTLP export call (incl. retries). Bounds CLI-exit/request stalls when the collector is unreachable; batches slower than this are dropped. | +| `otlp_protocol` | "http" | "http" (default; exporter ships with pr-agent) or "grpc" (requires the otel-grpc extra: pip install pr-agent[otel-grpc]) | +| `prometheus_multiproc_dir` | "/tmp/pr-agent-prometheus" | shared dir for the prometheus exporter's per-worker state files; required when exporter_type = "prometheus" (multiprocess gunicorn deployments); created 0700 and must stay owned by the run user (symlink or foreign-owner paths are refused) | +| `include_pr_url` | false | set to true to attach PR URLs to spans (may expose private repo names) | +| `include_error_details` | false | set to true to attach exception messages and rejected-command text to spans (may expose PR URLs, repo names, or other request content) | + + +## `[pr_similar_issue]` + +| Key | Default | Description | +| --- | --- | --- | +| `skip_comments` | false | | +| `force_update_dataset` | false | | +| `max_issues_to_scan` | 500 | | +| `vectordb` | "lancedb" | options: "pinecone", "lancedb", "qdrant" | + + +## `[pinecone]` + +| Key | Default | Description | +| --- | --- | --- | +| `cloud` | "aws" | Serverless index deployment for the modern SDK. `cloud` is one of "aws", "gcp" or "azure"; pick a `region` offered by that cloud. | +| `region` | "us-east-1" | | + + +## `[lancedb]` + +| Key | Default | Description | +| --- | --- | --- | +| `uri` | "./lancedb" | | + + +## `[qdrant]` + +_This section only documents commented-out examples; see the [TOML source](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml) for details._ + +## `[skills]` + +| Key | Default | Description | +| --- | --- | --- | +| `enabled` | false | Agent skills (SKILL.md) support: discovers SKILL.md files from the configured filesystem paths and injects their content into review/improve/describe and top-level /ask prompts. Sibling *.md files in the skill directory tree (e.g. references/guide.md) are inlined alongside SKILL.md. PR-Agent supports text-only skills: scripts/ and assets/ subdirectories are skipped because PR-Agent uses a single-shot model call (no tool-use loop) and cannot execute scripts or load binary assets on demand. Skills that depend on script execution will not work here. See https://github.com/The-PR-Agent/pr-agent/issues/2384 | +| `paths` | [] | directories to scan recursively for "*/SKILL.md"; supports ~ and $VAR | +| `max_skills_tokens` | 8000 | token budget for the combined skills_context block | + + +## `[artifacts]` + +| Key | Default | Description | +| --- | --- | --- | +| `enable` | false | Enable artifact injection into tool prompts (off by default; auto-enabled when artifact_path input is set) | +| `artifact_path` | "" | File path to the artifact (relative to GITHUB_WORKSPACE, or absolute) | +| `artifact_instructions` | "" | Custom instructions appended after the artifact content (leave empty for a sensible default) | +| `artifact_label` | "" | Label shown to the AI — defaults to the filename when empty. | +| `target_tools` | ["pr_reviewer", "pr_description", "pr_code_suggestions"] | Which tools receive artifact context. | +| `max_artifact_size` | 50000 | Max artifact size in characters (content is truncated if exceeded) | + + +## `[mosaico]` + +| Key | Default | Description | +| --- | --- | --- | +| `health_timeout_seconds` | 10 | finite positive seconds for cooperative health-probe work; excludes synchronous initialization and blocking SDK work | + + +## `[asana]` + +| Key | Default | Description | +| --- | --- | --- | +| `request_timeout` | 10 | seconds allowed for each Asana task API request | + + +## `[azure_devops]` + +| Key | Default | Description | +| --- | --- | --- | +| `default_comment_status` | "closed" | | + + +## `[azure_devops_server]` + +| Key | Default | Description | +| --- | --- | --- | +| `agent_identity` | "" | empty: discover the identity from earlier agent comments on the PR | + + +## `[push_outputs]` — push tool outputs to external sinks without calling git-provider APIs (disabled by default) + +| Key | Default | Description | +| --- | --- | --- | +| `enable` | false | | +| `channels` | [] | any of: "stdout", "file", "webhook", "slack". Nothing is emitted until a channel is listed here | +| `file_path` | "pr-agent-outputs/reviews.jsonl" | used by the "file" channel | +| `webhook_url` | "" | used by the "webhook" channel: generic JSON POST target. Must be an absolute https:// URL | +| `slack_webhook_url` | "" | used by the "slack" channel: a Slack Incoming Webhook URL. Must be an absolute https:// URL | diff --git a/docs/docs/usage-guide/custom_ca_and_self_signed_certificates.md b/docs/docs/usage-guide/custom_ca_and_self_signed_certificates.md new file mode 100644 index 0000000000..cf2cd88f25 --- /dev/null +++ b/docs/docs/usage-guide/custom_ca_and_self_signed_certificates.md @@ -0,0 +1,46 @@ +## Custom CA and self-signed certificates + +When PR-Agent runs behind a corporate TLS-inspecting proxy or against servers using self-signed certificates, HTTPS calls to the LLM provider and git operations can fail with `certificate verify failed` errors. The fix requires two pieces: telling the HTTP clients which CA bundle to trust, and making LiteLLM use a transport that honours that bundle. + +### Environment variables for CA trust + +PR-Agent reads three environment variables when building the git clone environment, in the order below. The first one that is **set** wins, and if it points to a file that does not exist PR-Agent logs a warning and configures no bundle for git rather than falling through to the next variable: + +| Variable | Scope | Notes | +|---|---|---| +| `SSL_CERT_FILE` | git clone and Python `requests` | Preferred. Used by most Python SSL contexts. | +| `REQUESTS_CA_BUNDLE` | git clone and Python `requests` | Fallback. Honoured by the `requests` library directly. | +| `GIT_SSL_CAINFO` | git clone only | Last resort. Used by git when the other two are absent. | + +If more than one variable is set and points to a different file, PR-Agent logs a warning and picks one according to the precedence above. All three variables should point to the same PEM file in practice. + +Example (runner environment or shell profile): + +```bash +export SSL_CERT_FILE=/etc/ssl/certs/corporate-ca-bundle.crt +``` + +These variables are read from the process environment, so they must be exported in the runner or shell profile; a `.secrets.toml` `[env]` section does **not** reach `os.environ` and cannot set them. + +### LiteLLM transport fallback + +By default LiteLLM uses aiohttp for HTTP calls, and aiohttp does not honour the standard CA environment variables. Setting `litellm.disable_aiohttp` makes LiteLLM fall back to httpx, which reads `SSL_CERT_FILE` (and `SSL_CERT_DIR`). httpx does not read `REQUESTS_CA_BUNDLE`, so the LLM call needs `SSL_CERT_FILE` specifically. + +```toml +[litellm] +disable_aiohttp = true +``` + +This setting is read once when the LiteLLM handler initialises, so it must be present before the process starts (runner environment or `.secrets.toml`, not a runtime override). + +### Scope of `gitlab.ssl_verify` + +The `gitlab.ssl_verify` setting (or `gitlab__SSL_VERIFY` environment variable) is passed only to the python-gitlab client that talks to the GitLab API. It does **not** affect the LLM call, git clone operations, or any other HTTPS client in the process. If you are seeing certificate errors from LiteLLM or git clone, use the environment variables above instead. + +### Putting it all together + +For a runner behind a corporate proxy with a custom CA: + +1. Export `SSL_CERT_FILE` pointing to your CA bundle. `REQUESTS_CA_BUNDLE` and `GIT_SSL_CAINFO` cover git clone only. +2. Set `litellm.disable_aiohttp = true` in your configuration. +3. If you also use the GitLab API, keep `gitlab.ssl_verify` pointed at the same bundle for the python-gitlab client. diff --git a/docs/docs/usage-guide/extending_pr_agent.md b/docs/docs/usage-guide/extending_pr_agent.md index cecd9aa095..e8a5252314 100644 --- a/docs/docs/usage-guide/extending_pr_agent.md +++ b/docs/docs/usage-guide/extending_pr_agent.md @@ -37,7 +37,7 @@ Verify with `PYTHONPATH=. uv run pytest tests/unittest`. Implement a `GitProvider` subclass and register it: 1. Create `pr_agent/git_providers/_provider.py`, extending the interface in `pr_agent/git_providers/git_provider.py` (`gitlab_provider.py` is the reference). -2. Register the class in `_GIT_PROVIDERS` in `pr_agent/git_providers/__init__.py`. Keys already used: `github`, `gitlab`, `bitbucket`, `bitbucket_server`, `azure`, `codecommit`, `local`, `gerrit`, `gitea`, `plain-diff`. +2. Add the built-in provider to `_BUILTIN_GIT_PROVIDERS` in `pr_agent/git_providers/__init__.py` as a `(module_path, class_name)` pair. Built-ins are imported lazily when selected. Keys already used: `github`, `gitlab`, `bitbucket`, `bitbucket_server`, `azure`, `codecommit`, `local`, `gerrit`, `gitea`, `plain-diff`. 3. Select it via `[config]` → `git_provider=""` in `pr_agent/settings/configuration.toml`. 4. Add `docs/docs/installation/.md` (see [`gitlab.md`](../installation/gitlab.md)) and register it under `Installation` in both `docs/mkdocs.yml` and `docs/docs/summary.md`. 5. Select provider-dependent behavior with capability checks like `provider.is_supported("feature")` rather than provider-type checks. diff --git a/docs/docs/usage-guide/index.md b/docs/docs/usage-guide/index.md index bb08ee6f1c..3f37280265 100644 --- a/docs/docs/usage-guide/index.md +++ b/docs/docs/usage-guide/index.md @@ -5,6 +5,7 @@ It includes information on how to adjust PR-Agent configurations, define which t - [Introduction](./introduction.md) - [Configuration File](./configuration_options.md) +- [Configuration Reference](./configuration_reference.md) - [Usage and Automation](./automations_and_usage.md) - [Local Repo (CLI)](./automations_and_usage.md#local-repo-cli) - [Online Usage](./automations_and_usage.md#online-usage) @@ -15,10 +16,10 @@ It includes information on how to adjust PR-Agent configurations, define which t - [BitBucket App](./automations_and_usage.md#bitbucket-app) - [Azure DevOps Provider](./automations_and_usage.md#azure-devops-provider) - [Managing Mail Notifications](./mail_notifications.md) +- [Push Outputs](./push_outputs.md) - [Changing a Model](./changing_a_model.md) - [Additional Configurations](./additional_configurations.md) - [Ignoring files from analysis](./additional_configurations.md#ignoring-files-from-analysis) - [Extra instructions](./additional_configurations.md#extra-instructions) - - [Working with large PRs](./additional_configurations.md#working-with-large-prs) - [Changing a model](./changing_a_model.md) - [FAQ](../faq/index.md) diff --git a/docs/docs/usage-guide/mail_notifications.md b/docs/docs/usage-guide/mail_notifications.md index ca6933be16..cfa6f6a32e 100644 --- a/docs/docs/usage-guide/mail_notifications.md +++ b/docs/docs/usage-guide/mail_notifications.md @@ -2,11 +2,11 @@ Unfortunately, it is not possible in GitHub to disable mail notifications from a specific user. If you are subscribed to notifications for a repo with PR-Agent, we recommend turning off notifications for PR comments, to avoid lengthy emails: -![notifications](https://codium.ai/images/pr_agent/notifications.png){width=512} +![notifications](../assets/notifications.png){width=512} As an alternative, you can filter in your mail provider the notifications specifically from the PR-Agent bot, [see how](https://www.quora.com/How-can-you-filter-emails-for-specific-people-in-Gmail#:~:text=On%20the%20Filters%20and%20Blocked,the%20body%20of%20the%20email). -![filter_mail_notifications](https://codium.ai/images/pr_agent/filter_mail_notifications.png){width=512} +![filter_mail_notifications](../assets/filter_mail_notifications.png){width=512} Another option to reduce the mail overload, yet still receive notifications on PR-Agent tools, is to disable the help collapsible section in PR-Agent bot comments. This can done by setting `enable_help_text=false` for the relevant tool in the configuration file. diff --git a/docs/docs/usage-guide/plain_diff_mode.md b/docs/docs/usage-guide/plain_diff_mode.md index d754e771a9..560dd59c68 100644 --- a/docs/docs/usage-guide/plain_diff_mode.md +++ b/docs/docs/usage-guide/plain_diff_mode.md @@ -26,20 +26,28 @@ python -m pr_agent.cli --diff-file changes.diff --output review.md review |---|---| | `--stdin` | Read a unified diff from stdin | | `--diff-file ` | Read a unified diff from a file | -| `--output ` | Write the result to a file in addition to stdout | -| `--json-output ` | Write the parsed review and token usage to a JSON file (`review` command) | +| `--output ` | Write Markdown produced by a compatible Plain Diff command to a file in addition to stdout | +| `--json-output ` | Write the parsed review and token usage to a JSON file (`review` or `review_pr` only) | `--stdin` and `--diff-file` are mutually exclusive. At least one must be provided to enter plain-diff mode; omitting both falls back to the normal `--pr_url` flow. +Output options must appear before the command. For example, use +`--stdin --output result.md review`, not `--stdin review --output result.md`. ### Supported commands -`review`, `improve`, `describe`, and `ask` are supported. Because there is no hosting +`review`, `improve`, `describe`, and `ask` are supported, along with their +`review_pr`, `improve_code`, `describe_pr`, and `ask_question` aliases. Because there is no hosting platform to push to, `improve` renders its code suggestions as a single markdown document to stdout (and to `--output`, if given) instead of as committable inline suggestions. Commands that require live platform interaction (such as `update_changelog` or `similar_issue`) are not meaningful in this mode. +Existing command variants that publish Markdown without additional platform +state (`auto_review`, `config`, `settings`, and `help`) also honor `--output`. +`answer` does not: it requires issue-comment history, which is unavailable when +the input is a standalone diff. + ## How it works 1. **Diff parsing** — the unified diff is parsed locally into per-file patch objects. @@ -55,9 +63,10 @@ suggestions. Commands that require live platform interaction (such as for that file. The review still runs; it simply has less context. 4. **Output** — the result is written to stdout. If `--output ` is given, it is - also written to that file (UTF-8, overwritten on each run). For `review`, if - `--json-output ` is given, the parsed review plus a `usage` object with the - run's accumulated token counts is also written to that file as JSON. + also written to that file (UTF-8, overwritten on each run). For `review` or + `review_pr`, if `--json-output ` is given, the parsed review plus a + `usage` object with the run's accumulated token counts is also written to that + file as JSON. No platform token, no PR URL, and no internet access are required for the diff processing step itself. An LLM API key is still needed unless you configure a local model. diff --git a/docs/docs/usage-guide/push_outputs.md b/docs/docs/usage-guide/push_outputs.md new file mode 100644 index 0000000000..7fabba0dbc --- /dev/null +++ b/docs/docs/usage-guide/push_outputs.md @@ -0,0 +1,90 @@ +# Push outputs to external sinks + +The `[push_outputs]` feature routes finished tool output to external sinks — stdout, a JSONL file, a +generic webhook, or Slack — without calling git-provider APIs. It is disabled by default, and is +additive to normal publishing: when a tool finishes, the same result that is posted as a PR comment +is also emitted to the configured sinks. + +## What gets pushed + +Each finished tool run emits one record. The tools that currently emit are: + +| Tool | `type` in the record | +|---|---| +| `/review` | `review` | +| `/describe` | `describe` | +| `/improve` | `improve` | + +A record is a JSON object: + +```json +{ + "type": "review", + "timestamp": "2026-09-08T14:03:21+00:00", + "payload": {}, + "markdown": "# PR Reviewer Guide ..." +} +``` + +- `type` — which tool produced the output +- `timestamp` — the run completion time, UTC, ISO-8601 +- `payload` — the structured tool result +- `markdown` — the rendered comment text; present when the tool produces one + +## Configuration + +The defaults are defined at the end of the +[configuration file](https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml): + +```toml +[push_outputs] +enable = false +channels = [] # any of: "stdout", "file", "webhook", "slack" +file_path = "pr-agent-outputs/reviews.jsonl" +webhook_url = "" # must be an absolute https:// URL +slack_webhook_url = "" # Slack Incoming Webhook; must be an absolute https:// URL +``` + +- `enable` — master switch (default `false`). When `false`, nothing is emitted. +- `channels` — which sinks to use. Nothing is emitted until at least one channel is listed here. +- `file_path` — the file the `file` channel appends to. +- `webhook_url` — the endpoint the `webhook` channel POSTs the generic record to. +- `slack_webhook_url` — a Slack Incoming Webhook URL that the `slack` channel posts a `{"text": ...}` payload to. + +!!! danger "Host-only configuration" + The whole `[push_outputs]` section is **host-only**. A repository cannot set these keys: + keys supplied through a repo's local `.pr_agent.toml` are dropped, and CLI arguments + (`--push_outputs.webhook_url=...`, `--push_outputs={...}`) are blocked. This prevents a + pull request from redirecting review output to an attacker-controlled host, reaching + internal endpoints, or appending to arbitrary host files. Configure these values in the + PR-Agent host's own settings. + +### URL requirements + +`webhook_url` and `slack_webhook_url` must be absolute `https://` URLs with a host. Any other +value (for example a plain `http://` URL or a bare path) is ignored with a warning. Requiring +HTTPS keeps review text, which can quote private code, off plaintext transports. The host is +intentionally not restricted, so self-hosted collectors and Slack-compatible endpoints +(Mattermost, Rocket.Chat) are legitimate targets. + +Warnings log the setting name — never the URL value — because a webhook or Slack URL is itself a +credential. + +## Channels + +| Channel | Behaviour | +|---|---| +| `stdout` | Prints one JSON line (the record) to stdout. | +| `file` | Appends one JSON line per run (JSONL) to `file_path`, creating parent directories as needed. | +| `webhook` | POSTs the generic record as JSON to `webhook_url` (5-second timeout, redirects not followed). | +| `slack` | POSTs `{"text": ...}` to a Slack Incoming Webhook; the text is the markdown, or the payload JSON when the tool produces no markdown. | + +Local channels (`stdout`, `file`) run before network channels (`webhook`, `slack`), and network +posts never follow redirects. Each configured destination is attempted independently, so one failure +does not prevent later destinations from receiving the output. + +## Error handling + +Failures are non-fatal: `push_outputs` never raises, so a sink outage does not break the review +flow. Exceptions and non-2xx HTTP responses are logged with the destination and only the exception +type or status code, since request error messages can embed the (secret-bearing) URL. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 98774f1352..cc501eb20b 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -15,15 +15,19 @@ nav: - BitBucket Integration: 'installation/bitbucket.md' - Azure DevOps Integration: 'installation/azure.md' - Gitea Integration: 'installation/gitea.md' + - MOSAICO A2A Server: 'installation/mosaico_server.md' - Usage Guide: - 'usage-guide/index.md' - Introduction: 'usage-guide/introduction.md' - Configuration File: 'usage-guide/configuration_options.md' + - Configuration Reference: 'usage-guide/configuration_reference.md' - Usage and Automation: 'usage-guide/automations_and_usage.md' - Managing Mail Notifications: 'usage-guide/mail_notifications.md' + - Push Outputs: 'usage-guide/push_outputs.md' - Changing a Model: 'usage-guide/changing_a_model.md' - Extending PR-Agent: 'usage-guide/extending_pr_agent.md' - Additional Configurations: 'usage-guide/additional_configurations.md' + - Custom CA and Self-Signed Certificates: 'usage-guide/custom_ca_and_self_signed_certificates.md' - Plain-Diff Mode: 'usage-guide/plain_diff_mode.md' - Local Git Provider: 'usage-guide/local_git_provider.md' - Frequently Asked Questions: 'faq/index.md' diff --git a/docs/overrides/main.html b/docs/overrides/main.html index 7d729c5aab..43eb6b3525 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -3,12 +3,3 @@ {% block announce %} Open source PR Agent documentation. For the Qodo free version for open-source projects, visit: https://www.qodo.ai/solutions/open-source/ {% endblock %} - -{% block scripts %} - {{ super() }} - - - - -{% endblock %} diff --git a/docs/overrides/partials/integrations/analytics/custom.html b/docs/overrides/partials/integrations/analytics/custom.html deleted file mode 100644 index 63dc6503aa..0000000000 --- a/docs/overrides/partials/integrations/analytics/custom.html +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/pr_agent/agent/pr_agent.py b/pr_agent/agent/pr_agent.py index 2414055b6c..388c5c5233 100644 --- a/pr_agent/agent/pr_agent.py +++ b/pr_agent/agent/pr_agent.py @@ -1,15 +1,21 @@ import asyncio +import copy import json import shlex from functools import partial +import dynaconf from opentelemetry.trace import StatusCode +from starlette_context import context, request_cycle_context from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.algo.cli_args import CliArgs +from pr_agent.algo.comment_identity import add_comment_identity, comment_matches_identity from pr_agent.algo.utils import update_settings_from_args -from pr_agent.config_loader import get_settings +from pr_agent.config_loader import get_settings, global_settings +from pr_agent.git_providers import get_git_provider_with_context +from pr_agent.git_providers.git_provider import IncompletePullRequestFilesError from pr_agent.git_providers.utils import apply_repo_settings from pr_agent.log import get_logger from pr_agent.telemetry.meter import get_commands_counter @@ -53,6 +59,71 @@ commands = list(command2class.keys()) +INCOMPLETE_GITHUB_FILES_COMMENT_MARKER = "" +INCOMPLETE_GITHUB_FILES_COMMENT = ( + "## PR-Agent command was not run\n\n" + "GitHub returned an incomplete or inconsistent changed-file set for this pull request, so PR-Agent stopped " + "instead of analyzing only part of it.\n\n" + "GitHub limits changed-file responses to 3,000 files. If this pull request changes more than 3,000 files, " + "split it into smaller pull requests and run the command again. Otherwise, retry the command." +) + + +def publish_incomplete_github_files_comment(pr_url: str) -> None: + """Publish one trusted, sanitized PR-level notice without replacing the primary failure.""" + try: + _publish_incomplete_github_files_comment(pr_url) + except Exception: + # Preserve the original completeness failure by containing every + # ordinary provider or rendering failure from this secondary notice. + get_logger().exception("Failed to prepare the incomplete-files notice") + + +def _publish_incomplete_github_files_comment(pr_url: str) -> None: + if not get_settings().get("CONFIG.PUBLISH_OUTPUT", True): + return + + try: + provider = get_git_provider_with_context(pr_url) + except Exception: + get_logger().exception("Failed to get a GitHub provider for the incomplete-files notice") + return + + try: + comments = provider.get_issue_comments_newest_first() + except Exception: + get_logger().exception("Failed to inspect existing incomplete-files notices") + comments = [] + + for comment in comments: + try: + body = provider._get_comment_body(comment) + except Exception: + # Ignore comments whose bodies cannot be read. Continue looking + # for a verifiable PR-Agent marker and publish if none can be + # confirmed. + get_logger().warning( + "Failed to read an existing incomplete-files notice; continuing" + ) + continue + if not comment_matches_identity(body, INCOMPLETE_GITHUB_FILES_COMMENT_MARKER): + continue + try: + if provider.is_comment_authored_by_pr_agent(comment): + return + except Exception: + get_logger().exception("Failed to verify the author of an incomplete-files notice") + + body = add_comment_identity( + INCOMPLETE_GITHUB_FILES_COMMENT, + INCOMPLETE_GITHUB_FILES_COMMENT_MARKER, + provider, + ) + try: + provider.publish_comment(body) + except Exception: + get_logger().exception("Failed to publish the incomplete-files notice") + def _split_command(command: str) -> list[tuple[str, bool]]: """Split an auto command and retain whether each token was quoted. @@ -138,15 +209,8 @@ def flush_token(): return tokens -def prepare_command(command: str) -> list[str]: - """Apply command-line settings while preserving quoted argument boundaries. - - Webhook adapters use this before handing configured commands to ``PRAgent``. Parsing - with ``str.split(" ")`` breaks values such as ``--section.key=\"words with spaces\"``; - the tokenizer keeps the value as one argument and preserves explicit quoting for YAML. - Returning the token list avoids serializing it back to a string, which would otherwise - be re-parsed by ``PRAgent`` and could alter quoted arguments. - """ +def parse_command(command: str) -> list[str]: + """Normalize configured command strings to argv without applying settings.""" tokens = _split_command(command) if not tokens: return [] @@ -158,6 +222,19 @@ def prepare_command(command: str) -> list[str]: key, value = argument.split("=", 1) argument = f"{key}={json.dumps(value, ensure_ascii=False)}" args.append(argument) + return [action] + args + + +def prepare_command(command: str) -> list[str]: + """Apply configured command settings while retaining argument boundaries. + + Return argv so ``PRAgent`` does not parse the command again. Quoted setting + values retain their string type when passed to the settings loader. + """ + command_args = parse_command(command) + if not command_args: + return [] + action, *args = command_args kept, rejected = [], [] for argument in args: # Validate the key only. The value is free text - a review instruction may legitimately @@ -178,9 +255,11 @@ def prepare_command(command: str) -> list[str]: class PRAgent: def __init__(self, ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler): - self.ai_handler = ai_handler # will be initialized in run_action + self.ai_handler = ai_handler # handler factory passed to each tool when it is instantiated - async def _handle_request(self, pr_url, request, notify=None) -> bool: + async def _handle_request( + self, pr_url, request, notify=None, propagate_tool_errors: bool | None = None + ) -> bool: # Exceptions raised inside are caught below, but a BaseException (e.g. the # CancelledError a webhook timeout raises) still escapes the span, and the SDK # would auto-record its message and stacktrace — request content, so opt-in. @@ -193,9 +272,23 @@ async def _handle_request(self, pr_url, request, notify=None) -> bool: if get_settings().get("OTEL.INCLUDE_PR_URL", False): span.set_attribute("pr_agent.pr_url", pr_url) try: - return await self._run_command(pr_url, request, notify, span) + if propagate_tool_errors is None: + return await self._run_command(pr_url, request, notify, span) + try: + context["settings"] + except Exception: + # Create request-local settings before awaiting commands outside middleware. + with request_cycle_context({"settings": copy.deepcopy(global_settings)}): + return await self._run_command( + pr_url, request, notify, span, propagate_tool_errors=propagate_tool_errors + ) + return await self._run_command( + pr_url, request, notify, span, propagate_tool_errors=propagate_tool_errors + ) except Exception as e: get_logger().exception("Failed to process the command.") + if isinstance(e, IncompletePullRequestFilesError): + publish_incomplete_github_files_comment(pr_url) # Status carries no description: it is free text, and the exception # message can embed PR URLs, repo names, or other request content. span.set_status(StatusCode.ERROR) @@ -205,7 +298,9 @@ async def _handle_request(self, pr_url, request, notify=None) -> bool: span.record_exception(e) return False - async def _run_command(self, pr_url, request, notify, span) -> bool: + async def _run_command( + self, pr_url, request, notify, span, propagate_tool_errors: bool | None = None + ) -> bool: # First, apply repo specific settings if exists apply_repo_settings(pr_url) @@ -238,13 +333,15 @@ async def _run_command(self, pr_url, request, notify, span) -> bool: get_logger().info(f'User has set the response language to: {response_language}') for key in get_settings(): setting = get_settings().get(key) - if str(type(setting)) == "": + if isinstance(setting, dynaconf.DataDict): if hasattr(setting, 'extra_instructions'): current_extra_instructions = setting.extra_instructions # Define the language-specific instruction and the separator lang_instruction_text = (f"Your response MUST be written in the language corresponding " - f"to locale code: '{response_language}'. This is crucial.") + f"to locale code: '{response_language}'. This is crucial. " + f"Keep schema control values (such as 'No', 'Yes', 'None', " + f"'false') in their original English form and do not translate them.") separator_text = "\n======\n\nIn addition, " # Check if the specific language instruction is already present to avoid duplication @@ -276,26 +373,42 @@ async def _run_command(self, pr_url, request, notify, span) -> bool: span.set_attribute("pr_agent.command", action) get_commands_counter().add(1, {"pr_agent.command": action, "vcs.provider.name": _git_provider}) - with get_logger().contextualize(command=action, pr_url=pr_url): - get_logger().info("PR-Agent request handler started", analytics=True) - if action == "answer": - if notify: - notify() - await PRReviewer(pr_url, is_answer=True, args=args, ai_handler=self.ai_handler).run() - elif action == "auto_review": - await PRReviewer(pr_url, is_auto=True, args=args, ai_handler=self.ai_handler).run() - else: - if notify: - notify() - - await command2class[action](pr_url, ai_handler=self.ai_handler, args=args).run() - - span.set_status(StatusCode.OK) - return True + settings = get_settings() + if propagate_tool_errors is not None: + # Apply this after repository and command settings so callers that require an honest + # result cannot be overridden by either source. Restore it below for request isolation. + previous_propagation = settings.get("CONFIG.PROPAGATE_TOOL_ERRORS", False) + settings.set("CONFIG.PROPAGATE_TOOL_ERRORS", propagate_tool_errors) + try: + with get_logger().contextualize(command=action, pr_url=pr_url): + get_logger().info("PR-Agent request handler started", analytics=True) + if action == "answer": + if notify: + notify() + await PRReviewer(pr_url, is_answer=True, args=args, ai_handler=self.ai_handler).run() + elif action == "auto_review": + await PRReviewer(pr_url, is_auto=True, args=args, ai_handler=self.ai_handler).run() + else: + if notify: + notify() + + await command2class[action](pr_url, ai_handler=self.ai_handler, args=args).run() + + span.set_status(StatusCode.OK) + return True + finally: + if propagate_tool_errors is not None: + settings.set("CONFIG.PROPAGATE_TOOL_ERRORS", previous_propagation) - async def handle_request(self, pr_url, request, notify=None) -> bool: + async def handle_request( + self, pr_url, request, notify=None, propagate_tool_errors: bool | None = None + ) -> bool: try: - return await self._handle_request(pr_url, request, notify) + if propagate_tool_errors is None: + return await self._handle_request(pr_url, request, notify) + return await self._handle_request( + pr_url, request, notify, propagate_tool_errors=propagate_tool_errors + ) except Exception: # _handle_request already catches command failures and annotates the span; # this is the outer contract every caller relies on — webhook handlers and diff --git a/pr_agent/algo/__init__.py b/pr_agent/algo/__init__.py index 01ed447e95..36c77e5c2b 100644 --- a/pr_agent/algo/__init__.py +++ b/pr_agent/algo/__init__.py @@ -269,6 +269,12 @@ def _generate_claude_registries(families=None): ) +# MAX_TOKENS holds only entries that deliberately deviate from (or are absent +# from) LiteLLM's model registry. Exact LiteLLM duplicates were removed because +# get_max_tokens() already falls back to litellm.get_model_info() with the same +# value; generator-expanded Claude families (see _CLAUDE_MODEL_FAMILIES) remain +# because they also drive the no-temperature / extended-thinking registries and +# their 1M-context handling is a separate, deliberate judgement (issue #3196). MAX_TOKENS = { 'text-embedding-ada-002': 8000, 'gpt-3.5-turbo': 16000, @@ -280,37 +286,11 @@ def _generate_claude_registries(families=None): 'gpt-4': 8000, 'gpt-4-0613': 8000, 'gpt-4-32k': 32000, - 'gpt-4-1106-preview': 128000, # 128K, but may be limited by config.max_model_tokens - 'gpt-4-0125-preview': 128000, # 128K, but may be limited by config.max_model_tokens - 'gpt-4o': 128000, # 128K, but may be limited by config.max_model_tokens - 'gpt-4o-2024-05-13': 128000, # 128K, but may be limited by config.max_model_tokens - 'gpt-4-turbo-preview': 128000, # 128K, but may be limited by config.max_model_tokens - 'gpt-4-turbo-2024-04-09': 128000, # 128K, but may be limited by config.max_model_tokens - 'gpt-4-turbo': 128000, # 128K, but may be limited by config.max_model_tokens - 'gpt-4o-mini': 128000, # 128K, but may be limited by config.max_model_tokens - 'gpt-4o-mini-2024-07-18': 128000, # 128K, but may be limited by config.max_model_tokens - 'gpt-4o-2024-08-06': 128000, # 128K, but may be limited by config.max_model_tokens - 'gpt-4o-2024-11-20': 128000, # 128K, but may be limited by config.max_model_tokens 'gpt-4.5-preview': 128000, # 128K, but may be limited by config.max_model_tokens 'gpt-4.5-preview-2025-02-27': 128000, # 128K, but may be limited by config.max_model_tokens - 'gpt-4.1': 1047576, - 'gpt-4.1-2025-04-14': 1047576, - 'gpt-4.1-mini': 1047576, - 'gpt-4.1-mini-2025-04-14': 1047576, - 'gpt-4.1-nano': 1047576, - 'gpt-4.1-nano-2025-04-14': 1047576, - 'gpt-5-nano': 200000, # 200K, but may be limited by config.max_model_tokens - 'gpt-5-mini': 200000, # 200K, but may be limited by config.max_model_tokens - 'gpt-5': 200000, - 'gpt-5-2025-08-07': 200000, - 'gpt-5.1': 200000, - 'gpt-5.1-2025-11-13': 200000, 'gpt-5.1-chat-latest': 200000, - 'gpt-5.1-codex': 200000, - 'gpt-5.1-codex-mini': 200000, 'gpt-5.2': 400000, # 400K, but may be limited by config.max_model_tokens 'gpt-5.2-2025-12-11': 400000, # 400K, but may be limited by config.max_model_tokens - 'gpt-5.2-chat-latest': 128000, # 128K, but may be limited by config.max_model_tokens 'gpt-5.2-codex': 400000, # 400K, but may be limited by config.max_model_tokens 'gpt-5.3-codex': 400000, # 400K, but may be limited by config.max_model_tokens 'gpt-5.3-chat': 128000, # 128K, but may be limited by config.max_model_tokens @@ -320,8 +300,6 @@ def _generate_claude_registries(families=None): 'gpt-5.4-mini-2026-03-17': 400000, # 400K, but may be limited by config.max_model_tokens 'gpt-5.4-nano': 400000, # 400K, but may be limited by config.max_model_tokens 'gpt-5.4-nano-2026-03-17': 400000, # 400K, but may be limited by config.max_model_tokens - 'gpt-5.5': 1050000, # 1.05M, but may be limited by config.max_model_tokens - 'gpt-5.5-2026-04-23': 1050000, # 1.05M, but may be limited by config.max_model_tokens 'gpt-5.6': 1050000, # 1.05M, but may be limited by config.max_model_tokens 'gpt-5.6-sol': 1050000, # 1.05M, but may be limited by config.max_model_tokens 'gpt-5.6-terra': 1050000, # 1.05M, but may be limited by config.max_model_tokens @@ -335,19 +313,10 @@ def _generate_claude_registries(families=None): 'o1': 204800, # 200K, but may be limited by config.max_model_tokens 'o3-mini': 204800, # 200K, but may be limited by config.max_model_tokens 'o3-mini-2025-01-31': 204800, # 200K, but may be limited by config.max_model_tokens - 'o3': 200000, # 200K, but may be limited by config.max_model_tokens - 'o3-2025-04-16': 200000, # 200K, but may be limited by config.max_model_tokens - 'o4-mini': 200000, # 200K, but may be limited by config.max_model_tokens - 'o4-mini-2025-04-16': 200000, # 200K, but may be limited by config.max_model_tokens 'claude-instant-1': 100000, 'claude-2': 100000, - 'command-nightly': 4096, 'deepseek/deepseek-chat': 128000, # 128K, but may be limited by config.max_model_tokens - 'deepseek/deepseek-reasoner': 64000, # 64K, but may be limited by config.max_model_tokens - 'deepseek/deepseek-v4-pro': 1000000, # 1M, but may be limited by config.max_model_tokens - 'deepseek/deepseek-v4-flash': 1000000, # 1M, but may be limited by config.max_model_tokens - 'zai/glm-5.2': 200000, # 200K, matching the Z.AI GLM-5/5.1 lineage, but may be limited by config.max_model_tokens - 'moonshot/kimi-k3': 262144, # 256K, matching the Moonshot Kimi-k2.5/k2.6 lineage, but may be limited by config.max_model_tokens + 'zai/glm-5.2': 1000000, # 1M per LiteLLM (issue #3196); kept pinned: absent from LiteLLM's bundled cost map 'openai/qwq-plus': 131072, # 131K context length, but may be limited by config.max_model_tokens "openrouter/auto": 2000000, # 2M context length, but may be limited by config.max_model_tokens "openrouter/free": 200000, # 200K context length, but may be limited by config.max_model_tokens @@ -362,8 +331,6 @@ def _generate_claude_registries(families=None): 'vertex_ai/claude-3-5-haiku@20241022': 100000, 'vertex_ai/claude-3-sonnet@20240229': 100000, 'vertex_ai/claude-3-opus@20240229': 100000, - 'vertex_ai/claude-opus-4@20250514': 200000, - 'vertex_ai/claude-opus-4-1@20250805': 200000, 'vertex_ai/claude-3-5-sonnet@20240620': 100000, 'vertex_ai/claude-3-5-sonnet-v2@20241022': 100000, 'vertex_ai/claude-sonnet-4@20250514': 200000, @@ -372,96 +339,55 @@ def _generate_claude_registries(families=None): 'vertex_ai/gemini-2.5-pro-preview-03-25': 1048576, 'vertex_ai/gemini-2.5-pro-preview-05-06': 1048576, 'vertex_ai/gemini-2.5-pro-preview-06-05': 1048576, - 'vertex_ai/gemini-2.5-pro': 1048576, 'vertex_ai/gemini-1.5-flash': 1048576, - 'vertex_ai/gemini-2.0-flash': 1048576, 'vertex_ai/gemini-2.5-flash-preview-04-17': 1048576, 'vertex_ai/gemini-2.5-flash-preview-05-20': 1048576, - 'vertex_ai/gemini-2.5-flash': 1048576, - 'vertex_ai/gemini-3-flash-preview': 1048576, - 'vertex_ai/gemini-3-pro-preview': 1048576, 'vertex_ai/gemini-3.1-flash': 1048576, 'vertex_ai/gemini-3.1-pro': 1048576, - 'vertex_ai/gemini-3.1-flash-lite-preview': 1048576, - 'vertex_ai/gemini-3.1-pro-preview': 1048576, - 'vertex_ai/gemini-3.5-flash': 1048576, - 'vertex_ai/gemini-3.5-flash-lite': 1048576, 'vertex_ai/gemini-3.5-pro': 1048576, - 'vertex_ai/gemini-3.6-flash': 1048576, - 'vertex_ai/gemini-3.7-flash': 1048576, - 'vertex_ai/gemini-3.8-flash': 1048576, + 'vertex_ai/gemini-3.8-flash': 1048576, # kept pinned: absent from LiteLLM's bundled cost map 'vertex_ai/gemma2': 8200, 'gemini/gemini-1.5-pro': 1048576, 'gemini/gemini-1.5-flash': 1048576, - 'gemini/gemini-2.0-flash': 1048576, 'gemini/gemini-2.5-flash-preview-04-17': 1048576, 'gemini/gemini-2.5-flash-preview-05-20': 1048576, - 'gemini/gemini-2.5-flash': 1048576, 'gemini/gemini-2.5-pro-preview-03-25': 1048576, 'gemini/gemini-2.5-pro-preview-05-06': 1048576, 'gemini/gemini-2.5-pro-preview-06-05': 1048576, - 'gemini/gemini-2.5-pro': 1048576, - 'gemini/gemini-3-flash-preview': 1048576, - 'gemini/gemini-3-pro-preview': 1048576, 'gemini/gemini-3.1-flash': 1048576, 'gemini/gemini-3.1-pro': 1048576, - 'gemini/gemini-3.1-flash-lite-preview': 1048576, - 'gemini/gemini-3.1-pro-preview': 1048576, - 'gemini/gemini-3.5-flash': 1048576, - 'gemini/gemini-3.5-flash-lite': 1048576, 'gemini/gemini-3.5-pro': 1048576, - 'gemini/gemini-3.6-flash': 1048576, - 'gemini/gemini-3.7-flash': 1048576, - 'gemini/gemini-3.8-flash': 1048576, + 'gemini/gemini-3.8-flash': 1048576, # kept pinned: absent from LiteLLM's bundled cost map 'codechat-bison': 6144, 'codechat-bison-32k': 32000, # -- Anthropic Claude -------------------------------------------------- - 'anthropic.claude-instant-v1': 100000, - 'anthropic.claude-v1': 100000, 'anthropic.claude-v2': 100000, 'anthropic/claude-3-opus-20240229': 100000, - 'anthropic/claude-opus-4-20250514': 200000, - 'anthropic/claude-opus-4-1-20250805': 200000, 'anthropic/claude-3-5-sonnet-20240620': 100000, 'anthropic/claude-3-5-sonnet-20241022': 100000, 'anthropic/claude-sonnet-4-20250514': 200000, # -- Bare Claude ------------------------------------------------------- - 'claude-opus-4-1-20250805': 200000, # -- Haiku ------------------------------------------------------------- 'anthropic/claude-3-5-haiku-20241022': 100000, # -- Bedrock Claude ---------------------------------------------------- - 'bedrock/anthropic.claude-instant-v1': 100000, 'bedrock/anthropic.claude-v2': 100000, - 'bedrock/anthropic.claude-v2:1': 100000, 'bedrock/anthropic.claude-3-sonnet-20240229-v1:0': 100000, - 'bedrock/anthropic.claude-opus-4-20250514-v1:0': 200000, - 'bedrock/anthropic.claude-opus-4-1-20250805-v1:0': 200000, 'bedrock/anthropic.claude-3-haiku-20240307-v1:0': 100000, 'bedrock/anthropic.claude-3-5-haiku-20241022-v1:0': 100000, 'bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0': 100000, 'bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0': 100000, 'bedrock/anthropic.claude-sonnet-4-20250514-v1:0': 200000, # -- Bedrock Claude (cross-region) ------------------------------------- - "bedrock/us.anthropic.claude-opus-4-20250514-v1:0": 200000, - "bedrock/us.anthropic.claude-opus-4-1-20250805-v1:0": 200000, "bedrock/us.anthropic.claude-3-5-sonnet-20241022-v2:0": 100000, "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0": 200000, "bedrock/global.anthropic.claude-sonnet-4-20250514-v1:0": 200000, "bedrock/apac.anthropic.claude-3-5-sonnet-20241022-v2:0": 100000, "bedrock/apac.anthropic.claude-sonnet-4-20250514-v1:0": 200000, - 'claude-3-5-sonnet': 100000, # -- Non-Claude models ------------------------------------------------- - 'bedrock/us.meta.llama4-scout-17b-instruct-v1:0': 128000, - 'bedrock/us.meta.llama4-maverick-17b-instruct-v1:0': 128000, "bedrock_mantle/xai.grok-4.3": 1000000, # 1M context, but may be limited by config.max_model_tokens - 'groq/openai/gpt-oss-120b': 131072, - 'groq/openai/gpt-oss-20b': 131072, - 'groq/qwen/qwen3-32b': 131000, 'dashscope/qwen3.8-max': 1000000, # 1M, qwen3.8-max is the actual DashScope model id (context_window 1M per QwenCode metadata), but may be limited by config.max_model_tokens 'groq/moonshotai/kimi-k2-instruct': 131072, 'groq/deepseek-r1-distill-llama-70b': 128000, - 'groq/meta-llama/llama-4-maverick-17b-128e-instruct': 131072, - 'groq/meta-llama/llama-4-scout-17b-16e-instruct': 131072, 'groq/llama-3.3-70b-versatile': 128000, 'groq/llama-3.1-8b-instant': 128000, 'sambanova/MiniMax-M3': 192000, @@ -473,44 +399,17 @@ def _generate_claude_registries(families=None): 'xai/grok-2': 131072, 'xai/grok-2-1212': 131072, 'xai/grok-2-latest': 131072, - 'xai/grok-3': 131072, - 'xai/grok-3-beta': 131072, 'xai/grok-3-fast': 131072, - 'xai/grok-3-fast-beta': 131072, - 'xai/grok-3-mini': 131072, - 'xai/grok-3-mini-beta': 131072, - 'xai/grok-3-mini-fast': 131072, - 'xai/grok-3-mini-fast-beta': 131072, - "xai/grok-4.5": 500000, # 500K context, but may be limited by config.max_model_tokens - "xai/grok-4.5-latest": 500000, - "xai/grok-build-latest": 500000, - "xai/grok-4.6": 500000, # 500K context, but may be limited by config.max_model_tokens - "openrouter/x-ai/grok-4.5": 500000, - "openrouter/x-ai/grok-4.6": 500000, - 'ollama/llama3': 4096, + "xai/grok-build-latest": 500000, # kept pinned: absent from LiteLLM's bundled cost map + "openrouter/x-ai/grok-4.5": 500000, # kept pinned: absent from LiteLLM's bundled cost map + "openrouter/x-ai/grok-4.6": 500000, # kept pinned: absent from LiteLLM's bundled cost map 'watsonx/meta-llama/llama-3-8b-instruct': 4096, "watsonx/meta-llama/llama-3-70b-instruct": 4096, "watsonx/meta-llama/llama-3-405b-instruct": 16384, "watsonx/ibm/granite-13b-chat-v2": 8191, "watsonx/ibm/granite-34b-code-instruct": 8191, - "watsonx/mistralai/mistral-large": 32768, "deepinfra/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B": 128000, "deepinfra/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": 128000, - "deepinfra/deepseek-ai/DeepSeek-R1": 128000, - "mistral/mistral-small-latest": 8191, - "mistral/mistral-medium-latest": 8191, - "mistral/mistral-large-2407": 128000, - "mistral/mistral-large-latest": 128000, - "mistral/open-mistral-7b": 8191, - "mistral/open-mixtral-8x7b": 8191, - "mistral/open-mixtral-8x22b": 8191, - "mistral/codestral-latest": 8191, - "mistral/open-mistral-nemo": 128000, - "mistral/open-mistral-nemo-2407": 128000, - "mistral/open-codestral-mamba": 256000, - "mistral/codestral-mamba-latest": 256000, - "codestral/codestral-latest": 8191, - "codestral/codestral-2405": 8191, 'xiaomi_mimo/mimo-v2.5': 1048576, # 1M, matching the LiteLLM registry for mimo-v2.5, xiaomi_mimo/ is the native LiteLLM Xiaomi provider, but may be limited by config.max_model_tokens 'xiaomi_mimo/mimo-v2.5-pro': 1048576, # 1M, matching the LiteLLM registry for mimo-v2.5-pro, but may be limited by config.max_model_tokens # Provider-prefixed Claude model IDs generated from _CLAUDE_MODEL_FAMILIES @@ -562,34 +461,15 @@ def normalize_litellm_model(model: str, custom_llm_provider: str = "") -> str: *_claude_no_temp, ] -SUPPORT_REASONING_EFFORT_MODELS = [ - "o3-mini", - "o3-mini-2025-01-31", - "o3", - "o3-2025-04-16", - "o4-mini", - "o4-mini-2025-04-16", - # Gemini 2.5 exposes a thinking budget controlled by reasoning_effort. Without - # these entries a configured effort is silently dropped, so a runaway thinking - # trace can consume the whole output budget and return an empty completion. - # LiteLLM maps native provider paths to thinkingConfig.thinkingBudget, while - # LiteLLMAIHandler routes OpenRouter-prefixed forms through extra_body.reasoning. - "gemini-2.5-pro", - "gemini-2.5-flash", - # Register each published Grok id separately so provider-prefixed forms match - # and the allowlist below can clamp model-specific reasoning levels. - "grok-4.5", - "grok-4.5-latest", - "grok-build-latest", - "grok-4.6", -] - # Clamp OpenAI-only levels for always-on Grok reasoning; allow xhigh on 4.6+. GROK_REASONING_EFFORT_LEVELS = { "grok-4.5": {"low", "medium", "high"}, "grok-4.5-latest": {"low", "medium", "high"}, "grok-build-latest": {"low", "medium", "high"}, "grok-4.6": {"low", "medium", "high", "xhigh"}, + # 4.3 mirrors 4.5 and does not support xhigh. + "grok-4.3": {"low", "medium", "high"}, + "grok-4.3-latest": {"low", "medium", "high"}, } # Claude models that support "extended thinking" through the manual diff --git a/pr_agent/algo/ai_handlers/base_ai_handler.py b/pr_agent/algo/ai_handlers/base_ai_handler.py index 956fcaffda..a7cd67edf2 100644 --- a/pr_agent/algo/ai_handlers/base_ai_handler.py +++ b/pr_agent/algo/ai_handlers/base_ai_handler.py @@ -15,6 +15,20 @@ def __init__(self): def deployment_id(self): pass + def build_request_messages( + self, + model: str, + system_prompt: str, + user_prompt: str, + *, + image_path: str | None = None, + ) -> list[dict]: + """Build the text-only message payload dispatched by the base handler contract.""" + return [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + @abstractmethod async def chat_completion(self, model: str, system: str, user: str, temperature: float = 0.2, img_path: str = None): """ diff --git a/pr_agent/algo/ai_handlers/cloud_auth.py b/pr_agent/algo/ai_handlers/cloud_auth.py new file mode 100644 index 0000000000..fe77f40a4b --- /dev/null +++ b/pr_agent/algo/ai_handlers/cloud_auth.py @@ -0,0 +1,1887 @@ +"""Cloud-credential plumbing for the LiteLLM model handler. + +Extracted verbatim from ``litellm_ai_handler`` (see issue #3508): Azure OIDC +token exchange, Vertex WIF/ADC loading, Bedrock Mantle request signing, IMDS +snapshots, SDK header bridges and the request-local API-key guard overrides +they install. Behaviour is deliberately unchanged: ``litellm_ai_handler`` +re-imports every name defined here, so module attributes and bare references +resolve exactly as before the move. +""" + +import configparser +import copy +import inspect +import json +import os +import re +import shutil +import sys +from contextvars import ContextVar +from functools import lru_cache, wraps +from types import FunctionType, SimpleNamespace +from urllib.parse import urlparse + +import litellm +import openai + +try: + from litellm.llms.anthropic.common_utils import AnthropicModelInfo +except ImportError: + AnthropicModelInfo = None + +try: + from litellm.llms.openai_like.json_loader import JSONProviderRegistry +except ImportError: + JSONProviderRegistry = None + +try: + from litellm.utils import _get_model_info_helper +except ImportError: + _get_model_info_helper = None + +try: + from litellm.llms.bedrock_mantle.common_utils import BedrockMantleAuthMixin +except ImportError: + BedrockMantleAuthMixin = None + +_handler_module = None + + +def _handler_attr(name, default=None): + """Resolve an interface slot from the live ``litellm_ai_handler`` module at call time. + + Read the module registered under its canonical import name so guarded interface + checks observe the attrs that tests replace on the handler module, without importing + the handler back and forming an import cycle. ``_handler_module`` re-routes the + lookup at an isolated re-exec of the handler so fail-closed tests can prove its + guards fire. + """ + handler_module = _handler_module + if handler_module is None: + handler_module = sys.modules.get("pr_agent.algo.ai_handlers.litellm_ai_handler") + if handler_module is None: + return default + return getattr(handler_module, name, default) + + +def _resolve_provider_registry(): + """Return ``JSONProviderRegistry``, preferring the handler module's replaceable binding. + + The handler keeps its own guarded registry import and tests replace that module + attribute to simulate custom provider registries, so request-path lookups must read + the live binding rather than this module's import-time copy. + """ + return _handler_attr("JSONProviderRegistry", JSONProviderRegistry) + + +DUMMY_LITELLM_API_KEY = "dummy_key" # request-local guard against LiteLLM's process-wide key fallbacks +PROVIDER_SETTING_ALIASES = { + "aiohttp_openai": "openai", + "anthropic_text": "anthropic", + "azure_text": "azure", + "ollama_chat": "ollama", + "text-completion-inception": "inception", + "text-completion-openai": "openai", + "vertex_ai_beta": "vertex_ai", +} +# Keep chat-completion endpoint aliases in the same precedence order as LiteLLM 1.101.0. +# Native completion masks BASETEN_API_BASE, MISTRAL_API_BASE and ARK_API_BASE; +# VERTEX_API_BASE belongs to embedding, not chat. Do not promote them to explicit routing. +PROVIDER_API_BASE_ENV_VARS = { + "a2a": ("A2A_API_BASE",), + "ai21": ("AI21_API_BASE",), + "ai21_chat": ("AI21_API_BASE",), + "aiml": ("AIML_API_BASE",), + "aleph_alpha": ("ALEPH_ALPHA_API_BASE",), + "amazon_nova": ("AMAZON_NOVA_API_BASE",), + "anthropic": ("ANTHROPIC_API_BASE", "ANTHROPIC_BASE_URL"), + "anyscale": ("ANYSCALE_API_BASE",), + "azure_ai": ("AZURE_AI_API_BASE",), + "bedrock_mantle": ("BEDROCK_MANTLE_API_BASE",), + "cerebras": ("CEREBRAS_API_BASE",), + "chatgpt": ("CHATGPT_API_BASE", "OPENAI_CHATGPT_API_BASE"), + "cloudflare": ("CLOUDFLARE_API_BASE",), + "codestral": ("CODESTRAL_API_BASE",), + "text-completion-codestral": ("CODESTRAL_API_BASE",), + "cohere": ("COHERE_API_BASE",), + "cohere_chat": ("COHERE_API_BASE",), + "cometapi": ("COMETAPI_API_BASE",), + "dashscope": ("DASHSCOPE_API_BASE",), + "databricks": ("DATABRICKS_API_BASE",), + "datarobot": ("DATAROBOT_ENDPOINT",), + "deepinfra": ("DEEPINFRA_API_BASE",), + "deepseek": ("DEEPSEEK_API_BASE",), + "docker_model_runner": ("DOCKER_MODEL_RUNNER_API_BASE",), + "empower": ("EMPOWER_API_BASE",), + "featherless_ai": ("FEATHERLESS_AI_API_BASE", "FEATHERLESS_API_BASE"), + "fireworks_ai": ("FIREWORKS_API_BASE",), + "friendliai": ("FRIENDLI_API_BASE",), + "galadriel": ("GALADRIEL_API_BASE",), + "gdc": ("GDC_API_BASE",), + "gemini": ("GEMINI_API_BASE",), + "github": ("GITHUB_API_BASE",), + "github_copilot": ("GITHUB_COPILOT_API_BASE",), + "gigachat": ("GIGACHAT_API_BASE",), + "gradient_ai": ("GRADIENT_AI_AGENT_ENDPOINT",), + "groq": ("GROQ_API_BASE",), + "heroku": ("HEROKU_API_BASE",), + "hosted_vllm": ("HOSTED_VLLM_API_BASE",), + "huggingface": ("HF_API_BASE", "HUGGINGFACE_API_BASE"), + "hyperbolic": ("HYPERBOLIC_API_BASE",), + "inception": ("INCEPTION_API_BASE",), + "lambda_ai": ("LAMBDA_API_BASE",), + "langflow": ("LANGFLOW_API_BASE",), + "langgraph": ("LANGGRAPH_API_BASE",), + "lemonade": ("LEMONADE_API_BASE",), + "litellm_proxy": ("LITELLM_PROXY_API_BASE",), + "llamafile": ("LLAMAFILE_API_BASE",), + "lm_studio": ("LM_STUDIO_API_BASE",), + "manus": ("MANUS_API_BASE",), + "maritalk": ("MARITALK_API_BASE",), + "meta_llama": ("LLAMA_API_BASE",), + "minimax": ("MINIMAX_API_BASE",), + "mistral": ("MISTRAL_AZURE_API_BASE",), + "modelscope": ("MODELSCOPE_API_BASE",), + "moonshot": ("MOONSHOT_API_BASE",), + "morph": ("MORPH_API_BASE",), + "nebius": ("NEBIUS_API_BASE",), + "novita": ("NOVITA_API_BASE",), + "nscale": ("NSCALE_API_BASE",), + "nvidia_nim": ("NVIDIA_NIM_API_BASE",), + "nvidia_riva": ("NVIDIA_RIVA_API_BASE",), + "nlp_cloud": ("NLP_CLOUD_API_BASE",), + "ollama": ("OLLAMA_API_BASE",), + "openai_like": ("OPENAI_LIKE_API_BASE",), + "openrouter": ("OPENROUTER_API_BASE",), + "ovhcloud": ("OVHCLOUD_API_BASE",), + "perplexity": ("PERPLEXITY_API_BASE",), + "predibase": ("PREDIBASE_API_BASE",), + "ragflow": ("RAGFLOW_API_BASE",), + "replicate": ("REPLICATE_API_BASE",), + "sambanova": ("SAMBANOVA_API_BASE",), + "tencent": ("TENCENT_API_BASE",), + "together_ai": ("TOGETHER_AI_API_BASE",), + "v0": ("V0_API_BASE",), + "vercel_ai_gateway": ("VERCEL_AI_GATEWAY_API_BASE",), + "vertex_ai": ("VERTEXAI_API_BASE",), + "volcengine": ("VOLCENGINE_API_BASE",), + "wandb": ("WANDB_API_BASE",), + "xai": ("XAI_API_BASE",), + "xinference": ("XINFERENCE_API_BASE",), + "zai": ("ZAI_API_BASE",), +} + +_WATSONX_ROUTING_ENV_VARS = { + "api_base": ("WATSONX_API_BASE", "WATSONX_URL", "WX_URL", "WML_URL"), + "project_id": ("WATSONX_PROJECT_ID", "WX_PROJECT_ID", "PROJECT_ID"), + "space_id": ("WATSONX_DEPLOYMENT_SPACE_ID", "WATSONX_SPACE_ID", "WX_SPACE_ID", "SPACE_ID"), + "region_name": ("WATSONX_REGION", "WX_REGION", "REGION"), + "token": ("WATSONX_TOKEN",), + "zen_api_key": ("WATSONX_ZENAPIKEY",), +} + +PROVIDER_ROUTING_ENV_VARS = { + "azure": { + "api_base": ("AZURE_API_BASE", "AZURE_OPENAI_ENDPOINT"), + "api_version": ("AZURE_API_VERSION",), + }, + "bedrock": { + "aws_bedrock_runtime_endpoint": ("AWS_BEDROCK_RUNTIME_ENDPOINT",), + "aws_region_name": ("AWS_REGION_NAME", "AWS_REGION", "AWS_DEFAULT_REGION"), + }, + "bedrock_mantle": { + "aws_bedrock_runtime_endpoint": ("AWS_BEDROCK_RUNTIME_ENDPOINT",), + "aws_region_name": ("BEDROCK_MANTLE_REGION", "AWS_REGION_NAME", "AWS_REGION", "AWS_DEFAULT_REGION"), + }, + "cloudflare": { + "api_base": ("CLOUDFLARE_API_BASE", "CLOUDFLARE_ACCOUNT_ID"), + }, + "openai": { + "organization": ("OPENAI_ORGANIZATION",), + }, + "openrouter": {"api_base": ("OPENROUTER_API_BASE",)}, + "vertex_ai": { + "vertex_project": ("VERTEXAI_PROJECT", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT"), + "vertex_location": ("VERTEXAI_LOCATION", "VERTEX_LOCATION"), + }, + "watsonx": dict(_WATSONX_ROUTING_ENV_VARS), + "watsonx_text": dict(_WATSONX_ROUTING_ENV_VARS), +} + +# Keep aliases in the same precedence order as LiteLLM 1.101.0. +PROVIDER_API_KEY_ENV_VARS = { + "ai21": ("AI21_API_KEY",), + "ai21_chat": ("AI21_API_KEY",), + "aiml": ("AIML_API_KEY",), + "aleph_alpha": ("ALEPH_ALPHA_API_KEY", "ALEPHALPHA_API_KEY"), + "amazon_nova": ("AMAZON_NOVA_API_KEY",), + "anyscale": ("ANYSCALE_API_KEY",), + "anthropic": ("ANTHROPIC_API_KEY",), + "azure": ("AZURE_OPENAI_API_KEY", "AZURE_API_KEY"), + "azure_ai": ("AZURE_AI_API_KEY",), + "baseten": ("BASETEN_API_KEY",), + "bedrock": ("AWS_BEARER_TOKEN_BEDROCK",), + "bedrock_mantle": ("BEDROCK_MANTLE_API_KEY", "AWS_BEARER_TOKEN_BEDROCK"), + "bytez": ("BYTEZ_API_KEY",), + "cerebras": ("CEREBRAS_API_KEY",), + "clarifai": ("CLARIFAI_API_KEY",), + "cloudflare": ("CLOUDFLARE_API_KEY",), + "codestral": ("CODESTRAL_API_KEY",), + "cohere": ("COHERE_API_KEY", "CO_API_KEY"), + "cohere_chat": ("COHERE_API_KEY", "CO_API_KEY"), + "cometapi": ("COMETAPI_KEY",), + "compactifai": ("COMPACTIFAI_API_KEY",), + "custom_openai": ("OPENAI_API_KEY",), # Preserve the existing OpenAI-compatible fallback. + "dashscope": ("DASHSCOPE_API_KEY",), + "databricks": ("DATABRICKS_API_KEY",), + "datarobot": ("DATAROBOT_API_TOKEN",), + "deepinfra": ("DEEPINFRA_API_KEY",), + "deepseek": ("DEEPSEEK_API_KEY",), + "docker_model_runner": ("DOCKER_MODEL_RUNNER_API_KEY",), + "empower": ("EMPOWER_API_KEY",), + "featherless_ai": ("FEATHERLESS_AI_API_KEY", "FEATHERLESS_API_KEY"), + "fireworks_ai": ( + "FIREWORKS_API_KEY", + "FIREWORKS_AI_API_KEY", + "FIREWORKSAI_API_KEY", + "FIREWORKS_AI_TOKEN", + ), + "friendliai": ("FRIENDLIAI_API_KEY", "FRIENDLI_TOKEN"), + "galadriel": ("GALADRIEL_API_KEY",), + "gdc": ("GDC_API_KEY",), + "gemini": ("GOOGLE_API_KEY", "GEMINI_API_KEY", "PALM_API_KEY"), + "github": ("GITHUB_API_KEY",), + "gigachat": ("GIGACHAT_API_KEY", "GIGACHAT_CREDENTIALS"), + "gradient_ai": ("GRADIENT_AI_API_KEY",), + "groq": ("GROQ_API_KEY",), + "heroku": ("HEROKU_API_KEY",), + "hosted_vllm": ("HOSTED_VLLM_API_KEY",), + "huggingface": ("HF_TOKEN", "HUGGINGFACE_API_KEY"), + "hyperbolic": ("HYPERBOLIC_API_KEY",), + "inception": ("INCEPTION_API_KEY",), + "lambda_ai": ("LAMBDA_API_KEY",), + "langflow": ("LANGFLOW_API_KEY",), + "langgraph": ("LANGGRAPH_API_KEY",), + "lemonade": ("LEMONADE_API_KEY",), + "litellm_proxy": ("LITELLM_PROXY_API_KEY",), + "llamafile": ("LLAMAFILE_API_KEY",), + "lm_studio": ("LM_STUDIO_API_KEY",), + "manus": ("MANUS_API_KEY",), + "maritalk": ("MARITALK_API_KEY",), + "meta_llama": ("LLAMA_API_KEY",), + "minimax": ("MINIMAX_API_KEY",), + "mistral": ("MISTRAL_AZURE_API_KEY", "MISTRAL_API_KEY"), + "modelscope": ("MODELSCOPE_API_KEY",), + "moonshot": ("MOONSHOT_API_KEY",), + "morph": ("MORPH_API_KEY",), + "nebius": ("NEBIUS_API_KEY",), + "novita": ("NOVITA_API_KEY",), + "nscale": ("NSCALE_API_KEY",), + "nvidia_nim": ("NVIDIA_NIM_API_KEY",), + "nlp_cloud": ("NLP_CLOUD_API_KEY",), + "ollama": ("OLLAMA_API_KEY",), + "openai": ("OPENAI_API_KEY",), + "openai_like": ("OPENAI_LIKE_API_KEY",), + "openrouter": ("OPENROUTER_API_KEY", "OR_API_KEY"), + "ovhcloud": ("OVHCLOUD_API_KEY",), + "perplexity": ("PERPLEXITYAI_API_KEY", "PERPLEXITY_API_KEY"), + "predibase": ("PREDIBASE_API_KEY",), + "ragflow": ("RAGFLOW_API_KEY",), + "replicate": ("REPLICATE_API_KEY", "REPLICATE_API_TOKEN"), + "sambanova": ("SAMBANOVA_API_KEY",), + "sap": ("AICORE_SERVICE_KEY",), + "snowflake": ("SNOWFLAKE_JWT",), + "tencent": ("TENCENT_API_KEY",), + "text-completion-codestral": ("CODESTRAL_API_KEY",), + "together_ai": ( + "TOGETHER_API_KEY", + "TOGETHER_AI_API_KEY", + "TOGETHERAI_API_KEY", + "TOGETHER_AI_TOKEN", + ), + "v0": ("V0_API_KEY",), + "vercel_ai_gateway": ("VERCEL_AI_GATEWAY_API_KEY", "VERCEL_OIDC_TOKEN"), + "volcengine": ("VOLCENGINE_API_KEY",), + "wandb": ("WANDB_API_KEY",), + "watsonx": ("WATSONX_APIKEY", "WATSONX_API_KEY", "WX_API_KEY", "WATSONX_ZENAPIKEY"), + "watsonx_text": ("WATSONX_APIKEY", "WATSONX_API_KEY", "WX_API_KEY"), + "xai": ("XAI_API_KEY",), + "xiaomi_mimo": ("XIAOMI_MIMO_API_KEY",), + "xinference": ("XINFERENCE_API_KEY",), + "zai": ("ZAI_API_KEY",), +} + +OPENAI_COMPATIBLE_REQUEST_PROVIDERS = {"custom_openai", "openai_like"} +MANAGED_AUTH_REQUEST_PROVIDERS = {"chatgpt", "github_copilot"} +OPENAI_RAW_HTTP_REQUEST_PROVIDERS = { + "aiohttp_openai", + "azure_ai", + "cometapi", + "deepseek", + "fireworks_ai", + "groq", + "heroku", + "hosted_vllm", + "minimax", + "openai_like", + "openrouter", + "ragflow", + "together_ai", + "xai", +} +AZURE_OIDC_ENV_VARS = ("AZURE_CLIENT_ID", "AZURE_TENANT_ID", "AZURE_AUTHORITY_HOST", "AZURE_SCOPE") +PROVIDER_API_KEY_GLOBALS = { + "ai21": ("ai21_key",), + "ai21_chat": ("ai21_key",), + "aleph_alpha": ("aleph_alpha_key",), + "amazon_nova": ("amazon_nova_api_key",), + "anthropic": ("anthropic_key",), + "azure": ("azure_key",), + "baseten": ("baseten_key",), + "bytez": ("bytez_key",), + "cloudflare": ("cloudflare_api_key",), + "cohere": ("cohere_key",), + "cohere_chat": ("cohere_key",), + "cometapi": ("cometapi_key",), + "custom_openai": ("openai_key",), + "databricks": ("databricks_key",), + "gdc": ("gdc_key",), + "gigachat": ("gigachat_key",), + "groq": ("groq_key",), + "huggingface": ("huggingface_key",), + "inception": ("inception_key",), + "lemonade": ("lemonade_key",), + "maritalk": ("maritalk_key",), + "nebius": ("nebius_key",), + "nlp_cloud": ("nlp_cloud_key",), + "ollama": ("ollama_key", "openai_key"), + "openai": ("openai_key",), + "openai_like": ("openai_like_key",), + "openrouter": ("openrouter_key",), + "ovhcloud": ("ovhcloud_key",), + "predibase": ("predibase_key",), + "replicate": ("replicate_key",), + "sap": ("sap_service_key",), + "together_ai": ("togetherai_api_key",), + "wandb": ("wandb_key",), + "xai": ("xai_key",), +} +def _require_litellm_interface(interface, name: str, methods=()): + """Reject unavailable isolation interfaces without disabling their protection.""" + available = ( + all(callable(getattr(interface, method, None)) for method in methods) + if methods else callable(interface) + ) + if not available: + raise RuntimeError( + f"LiteLLM interface {name} is unavailable; request isolation cannot be guaranteed. " + "Restore the project's locked LiteLLM version or update and validate this integration." + ) + + +def _rebind_to_globals(native, native_globals): + """Rebind a native function onto captured globals, keeping its own defaults.""" + bound = FunctionType( + native.__code__, native_globals, native.__name__, native.__defaults__, native.__closure__, + ) + bound.__kwdefaults__ = copy.copy(native.__kwdefaults__) + return bound + + +_azure_oidc_request = ContextVar("pr_agent_azure_oidc_request", default=None) +_azure_ad_responses_request = ContextVar("pr_agent_azure_ad_responses_request", default=None) +_azure_oidc_bridge = None + + +def _exchange_azure_oidc_token(selector, environment, native=None): + """Bind the native outer exchange to captured inputs without mutating its globals.""" + from litellm.llms.azure.common_utils import get_azure_ad_token_from_oidc + + if native is None: + native = get_azure_ad_token_from_oidc + if _azure_oidc_bridge is not None and native is _azure_oidc_bridge["exchange"]: + native = _azure_oidc_bridge["original_exchange"] + if set(environment) != set(AZURE_OIDC_ENV_VARS): + raise RuntimeError("Azure OIDC exchange snapshot is incomplete") + if not isinstance(native, FunctionType): + raise RuntimeError("LiteLLM's Azure OIDC exchange interface is incompatible") + parameters = tuple(inspect.signature(native).parameters.values()) + names = ("azure_ad_token", "azure_client_id", "azure_tenant_id", "scope") + if len(parameters) != len(names) or any( + parameter.name != name + or parameter.kind != inspect.Parameter.POSITIONAL_OR_KEYWORD + or parameter.default is not (inspect.Parameter.empty if index == 0 else None) + for index, (parameter, name) in enumerate(zip(parameters, names, strict=True)) + ): + raise RuntimeError("LiteLLM's Azure OIDC exchange interface is incompatible") + native_globals = dict(native.__globals__) + cache = native_globals.get("azure_ad_cache") + _require_litellm_interface(cache, "Azure OIDC cache", ("get_cache", "set_cache")) + _require_litellm_interface(native_globals.get("get_secret_str"), "Azure OIDC assertion resolver") + + def captured_getenv(name, default=None): + if name not in environment: + raise RuntimeError("LiteLLM's Azure OIDC exchange uses an uncaptured input") + value = environment[name] + return default if value is None else value + + # Match the pinned helper's default without normalizing a deliberate empty scope. + scope = environment["AZURE_SCOPE"] + effective_scope = "https://cognitiveservices.azure.com/.default" if scope is None else scope + + def scoped_key(key): + return json.dumps(["pr-agent.azure-oidc.v1", effective_scope, key]) + + def get_cache(key, *args, **kwargs): + return cache.get_cache(scoped_key(key), *args, **kwargs) + + def set_cache(key, *args, **kwargs): + return cache.set_cache(scoped_key(key), *args, **kwargs) + + # Keep native assertion resolution (including deployment-owned source selection) + # and refresh/TTL behavior. Only the outer exchange's inputs are request-owned. + native_globals["os"] = SimpleNamespace(getenv=captured_getenv) + native_globals["azure_ad_cache"] = SimpleNamespace(get_cache=get_cache, set_cache=set_cache) + exchange = _rebind_to_globals(native, native_globals) + return exchange( + selector, + azure_client_id=environment["AZURE_CLIENT_ID"], + azure_tenant_id=environment["AZURE_TENANT_ID"], + scope=scope, + ) + + +@lru_cache(maxsize=128) +def _azure_oidc_entra_provider(tenant_id, client_id, client_secret, scope, authority): + from azure.identity import ClientSecretCredential, get_bearer_token_provider + + credential = ClientSecretCredential(tenant_id, client_id, client_secret, authority=authority) + return get_bearer_token_provider(credential, scope) + + +def _azure_oidc_companion_globals(native_globals, context): + """Bind companion factories only inside the selected native auth path.""" + native_globals = dict(native_globals) + original_entra = native_globals["get_azure_ad_token_from_entra_id"] + original_password = native_globals["get_azure_ad_token_from_username_password"] + + def authority(): + from azure.identity import AzureAuthorityHosts + + value = context["environment"]["AZURE_AUTHORITY_HOST"] + if value == "": + raise ValueError("Azure companion credentials require a nonempty captured authority") + return AzureAuthorityHosts.AZURE_PUBLIC_CLOUD if value is None else value + + def entra(tenant_id, client_id, client_secret, scope="https://cognitiveservices.azure.com/.default"): + if not isinstance(original_entra, FunctionType): + raise RuntimeError("LiteLLM's Azure Entra factory is incompatible") + captured_authority = authority() + + def cached(tenant_id, client_id, client_secret, scope): + return _azure_oidc_entra_provider(tenant_id, client_id, client_secret, scope, captured_authority) + + # Retain native secret-selector resolution, but never use the native + # authority-free provider cache. Credentials retain authority on refresh. + globals_view = {**original_entra.__globals__, "_cached_entra_id_token_provider": cached} + factory = _rebind_to_globals(original_entra, globals_view) + return factory(tenant_id, client_id, client_secret, scope) + + def password(client_id, azure_username, azure_password, scope="https://cognitiveservices.azure.com/.default"): + from azure.identity import UsernamePasswordCredential, get_bearer_token_provider + + if not isinstance(original_password, FunctionType): + raise RuntimeError("LiteLLM's Azure password factory is incompatible") + # The native password path leaves tenant selection at the SDK default. + credential = UsernamePasswordCredential( + client_id=client_id, username=azure_username, password=azure_password, authority=authority(), + ) + return get_bearer_token_provider(credential, scope) + + native_globals["get_azure_ad_token_from_entra_id"] = entra + native_globals["get_azure_ad_token_from_username_password"] = password + + def reject_implicit_refresh(*args, **kwargs): + # Native discovery re-reads ambient identity after companion selection. + # ValueError is swallowed by LiteLLM and would silently change auth. + raise RuntimeError("Request isolation forbids implicit Azure AD credential discovery; " + "configure complete companion credentials instead") + + native_globals["get_azure_ad_token_provider"] = reject_implicit_refresh + return native_globals + + +def _install_azure_oidc_bridge(): + """Bind native OIDC and SDK AD selection without changing key precedence.""" + global _azure_oidc_bridge + from litellm.llms.azure import azure as azure_module + from litellm.llms.azure import common_utils as azure_common + from litellm.llms.openai.common_utils import BaseOpenAILLM + + if _azure_oidc_bridge is not None: + if ( + azure_common.get_azure_ad_token_from_oidc is not _azure_oidc_bridge["exchange"] + or azure_module.get_azure_ad_token_from_oidc is not _azure_oidc_bridge["exchange"] + or azure_common.get_azure_ad_token is not _azure_oidc_bridge["resolver"] + or BaseOpenAILLM.get_openai_client_cache_key is not _azure_oidc_bridge["cache_key"] + or azure_common.BaseAzureLLM._resolve_env_var is not _azure_oidc_bridge["resolve_env"] + or azure_common.BaseAzureLLM.get_azure_openai_client is not _azure_oidc_bridge["client"] + or azure_common.BaseAzureLLM._base_validate_azure_environment is not _azure_oidc_bridge["validate"] + or azure_common.BaseAzureLLM.initialize_azure_sdk_client is not _azure_oidc_bridge["initialize"] + ): + raise RuntimeError("LiteLLM's Azure OIDC bridge interfaces were replaced") + return + original_exchange = azure_common.get_azure_ad_token_from_oidc + original_resolver = azure_common.get_azure_ad_token + original_cache_key = BaseOpenAILLM.get_openai_client_cache_key + original_resolve_env = azure_common.BaseAzureLLM._resolve_env_var + original_client = azure_common.BaseAzureLLM.get_azure_openai_client + original_validate = azure_common.BaseAzureLLM._base_validate_azure_environment + original_initialize = azure_common.BaseAzureLLM.initialize_azure_sdk_client + client_signature = inspect.signature(original_client) + initialize_signature = inspect.signature(original_initialize) + if ( + not all(isinstance(function, FunctionType) for function in ( + original_exchange, original_resolver, original_cache_key, original_resolve_env, + original_client, original_validate, original_initialize, + )) + or azure_module.get_azure_ad_token_from_oidc is not original_exchange + ): + raise RuntimeError("LiteLLM's Azure OIDC interfaces are incompatible") + + @wraps(original_exchange) + def exchange(azure_ad_token, azure_client_id=None, azure_tenant_id=None, scope=None): + context = _azure_oidc_request.get() + if context is not None and context["selector"] and azure_ad_token == context["selector"]: + return _exchange_azure_oidc_token(azure_ad_token, context["environment"], original_exchange) + return original_exchange(azure_ad_token, azure_client_id, azure_tenant_id, scope) + + def resolve_captured(litellm_params, context): + native_globals = _azure_oidc_companion_globals(original_resolver.__globals__, context) + native_os = native_globals["os"] + native_get_secret = native_globals["get_secret_str"] + + def get_secret_str(name, *args, **kwargs): + if name == "AZURE_AD_TOKEN": + return context["dispatch_token"] + return native_get_secret(name, *args, **kwargs) + + def getenv(name, default=None): + if name in context["auth_environment"]: + value = context["auth_environment"][name] + return default if value is None else value + return native_os.getenv(name, default) + + native_globals["os"] = SimpleNamespace(getenv=getenv) + native_globals["get_secret_str"] = get_secret_str + native_globals["get_azure_ad_token_from_oidc"] = exchange + bound = _rebind_to_globals(original_resolver, native_globals) + return bound(litellm_params) + + @wraps(original_resolver) + def resolver(litellm_params): + ad_context = _azure_ad_responses_request.get() + if ad_context is not None and litellm_params.get("azure_ad_token") == ad_context["dispatch_token"]: + return resolve_captured(litellm_params, ad_context) + context = _azure_oidc_request.get() + if context is None or not context["selector"] or litellm_params.get("azure_ad_token") != context["selector"]: + return original_resolver(litellm_params) + environment = context["environment"] + if not environment["AZURE_CLIENT_ID"] or not environment["AZURE_TENANT_ID"]: + raise ValueError("Azure OIDC requires captured client and tenant identifiers") + return resolve_captured(litellm_params, context) + + @wraps(original_resolve_env) + def resolve_env(owner, litellm_params, param_key, env_var_key): + context = _azure_oidc_request.get() or _azure_ad_responses_request.get() + if ( + context is not None + and ( + litellm_params.get("azure_ad_token") == context["dispatch_token"] + or (context.get("companion") and litellm_params.get("azure_ad_token") is None) + ) + and env_var_key in context["auth_environment"] + ): + value = litellm_params.get(param_key) + return value if value is not None else context["auth_environment"][env_var_key] + return original_resolve_env(owner, litellm_params, param_key, env_var_key) + + @wraps(original_initialize) + def initialize(*args, **kwargs): + context = _azure_oidc_request.get() or _azure_ad_responses_request.get() + if context is None: + return original_initialize(*args, **kwargs) + bound = initialize_signature.bind(*args, **kwargs) + params = bound.arguments.get("litellm_params") or {} + if not ( + params.get("azure_ad_token") == context["dispatch_token"] + or (context.get("companion") and params.get("azure_ad_token") is None) + ): + return original_initialize(*args, **kwargs) + native_globals = _azure_oidc_companion_globals(original_initialize.__globals__, context) + factory = _rebind_to_globals(original_initialize, native_globals) + return factory(*args, **kwargs) + + @wraps(original_cache_key) + def cache_key(client_initialization_params, client_type): + key = original_cache_key(client_initialization_params, client_type) + context = _azure_oidc_request.get() or _azure_ad_responses_request.get() + if ( + context is not None and client_type == "azure" + and ( + client_initialization_params.get("azure_ad_token") == context.get("selector_hash") + or ( + context.get("companion") + and client_initialization_params.get("azure_ad_token") is None + ) + ) + ): + return f"{key}|pr_agent_oidc={context['identity_hash']}" + return key + + @wraps(original_client) + def client(*args, **kwargs): + context = _azure_oidc_request.get() + if context is None: + context = _azure_ad_responses_request.get() + if context is None or not context["generated_guard"]: + return original_client(*args, **kwargs) + bound = client_signature.bind(*args, **kwargs) + params = bound.arguments.get("litellm_params") or {} + if ( + bound.arguments.get("api_key") == DUMMY_LITELLM_API_KEY + and ( + params.get("azure_ad_token") == context["dispatch_token"] + or (context.get("companion") and params.get("azure_ad_token") is None) + ) + ): + # Dispatch has already resolved fallbacks. Restore native keyless + # selection before the SDK initializer and its client-cache lookup. + bound.arguments["api_key"] = None + return original_client(*bound.args, **bound.kwargs) + + @wraps(original_validate) + def validate(headers, litellm_params): + ad_context = _azure_ad_responses_request.get() + if ( + ad_context is not None and ad_context["generated_guard"] and "api-key" not in headers + and litellm_params.get("api_key") == DUMMY_LITELLM_API_KEY + and litellm_params.get("azure_ad_token") == ad_context["dispatch_token"] + ): + # Only a positively identified generated guard may bypass key + # selection. Retain native AD provider precedence, not global keys. + headers = dict(headers) + token = resolve_captured(litellm_params, ad_context) + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + context = _azure_oidc_request.get() + if ( + context is None or not context["selector"] or not context["generated_guard"] or "api-key" in headers + or litellm_params.get("api_key") != DUMMY_LITELLM_API_KEY + or litellm_params.get("azure_ad_token") != context["dispatch_token"] + ): + return original_validate(headers, litellm_params) + # Do not retry process-wide API-key fallback with a cleared guard. + headers = dict(headers) + token = resolver(litellm_params) + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + azure_common.get_azure_ad_token_from_oidc = exchange + azure_module.get_azure_ad_token_from_oidc = exchange + azure_common.get_azure_ad_token = resolver + azure_common.BaseAzureLLM._resolve_env_var = resolve_env + azure_common.BaseAzureLLM.get_azure_openai_client = client + azure_common.BaseAzureLLM._base_validate_azure_environment = staticmethod(validate) + azure_common.BaseAzureLLM.initialize_azure_sdk_client = initialize + BaseOpenAILLM.get_openai_client_cache_key = staticmethod(cache_key) + _azure_oidc_bridge = { + "exchange": exchange, "resolver": resolver, "cache_key": cache_key, + "original_exchange": original_exchange, + "resolve_env": resolve_env, + "client": client, "validate": validate, "initialize": initialize, + } + + +def _is_openai_compatible_request_provider(provider: str) -> bool: + return ( + provider in getattr(litellm, "openai_compatible_providers", ()) + or _resolve_provider_registry().exists(provider) + ) + + +def _uses_openai_text_completion_transport(model: str | None, provider: str | None) -> bool: + """Return whether LiteLLM will dispatch this request through the OpenAI text-completion SDK.""" + if provider == "text-completion-openai": + return True + if not isinstance(model, str): + return False + return any(prefix in model for prefix in ("ft:babbage-002", "ft:davinci-002")) + + +def _uses_openai_responses_transport(model: str | None, provider: str | None) -> bool: + """Return whether LiteLLM will send this model through its raw Responses transport.""" + if not isinstance(model, str) or _uses_openai_text_completion_transport(model, provider): + return False + provider = provider or "" + canonical_provider = PROVIDER_SETTING_ALIASES.get(provider, provider) + if "/" in model and model.split("/", 1)[0] in (provider, canonical_provider): + model = model.split("/", 1)[1] + if model.startswith("responses/"): + return True + if provider == "openai" and getattr(litellm, "route_all_chat_openai_to_responses", False): + return True + model_info_helper = _handler_attr("_get_model_info_helper", _get_model_info_helper) + _require_litellm_interface(model_info_helper, "_get_model_info_helper") + try: + model_info = model_info_helper(model=model, custom_llm_provider=provider) + except Exception: + return False + return model_info.get("mode") == "responses" + + +def _uses_provider_api_key(provider: str) -> bool: + """Return whether PR-Agent should forward or guard this provider's native API key.""" + return provider in PROVIDER_API_KEY_ENV_VARS or _resolve_provider_registry().exists(provider) + + +_SDK_HEADER_MARKER = "x-pr-agent-sdk-header-snapshot" +_sdk_request_headers = ContextVar("pr_agent_sdk_request_headers", default=None) + + +class _CapturedSDKHeader(openai.Omit): + """Distinguish generated account guards from explicit header omissions.""" + + +class _SDKHeaderSnapshot: + # Keep credential-bearing snapshots out of SDK DEBUG and fallback serialization. + __slots__ = ("snapshot",) + + def __init__(self, snapshot): + self.snapshot = snapshot + + def __repr__(self): + return "" + + +def _merge_sdk_headers(*layers): + """Merge before HTTPX so differently cased names cannot duplicate credentials.""" + result = {} + names = {} + for layer in layers: + for name, value in layer.items(): + lower = name.lower() + canonical = names.setdefault(lower, name) + result[canonical] = value + return result + + +def _check_sdk_marker_collision(headers): + if any(name.lower() == _SDK_HEADER_MARKER for name in headers): + raise ValueError("Request headers contain a reserved SDK isolation header") + + +class _SDKHeaderLoggingProxy: + def __init__(self, logger): + object.__setattr__(self, "_logger", logger) + + def __getattr__(self, name): + return getattr(self._logger, name) + + def __setattr__(self, name, value): + setattr(self._logger, name, value) + + def pre_call(self, *args, **kwargs): + additional = kwargs.get("additional_args") + if isinstance(additional, dict) and isinstance(additional.get("complete_input_dict"), dict): + data = dict(additional["complete_input_dict"]) + if isinstance(data.get("extra_headers"), dict): + data["extra_headers"] = { + key: value for key, value in data["extra_headers"].items() + if key.lower() != _SDK_HEADER_MARKER + } + kwargs["additional_args"] = {**additional, "complete_input_dict": data} + return self._logger.pre_call(*args, **kwargs) + + +def _sdk_data_adapter(original, *, text=False, copilot=False): + signature = inspect.signature(original) + + @wraps(original) + def scoped(*args, **kwargs): + snapshot = _sdk_request_headers.get() + if snapshot is None: + return original(*args, **kwargs) + bound = signature.bind(*args, **kwargs) + if copilot: + if snapshot["provider"] != "github_copilot": + return original(*args, **kwargs) + params = bound.arguments.get("optional_params") or {} + bound.arguments["headers"] = _merge_sdk_headers( + params.get("extra_headers") or {}, bound.arguments.get("headers") or {}, + ) + else: + data = dict(bound.arguments["data"]) + if text and snapshot["provider"] == "azure": + # LiteLLM also copies this auth argument into text request data. + # Keep it on the native client parameters, never in the SDK payload. + data.pop("azure_ad_token", None) + headers = dict(data.get("extra_headers") or {}) + _check_sdk_marker_collision(headers) + headers[_SDK_HEADER_MARKER] = _SDKHeaderSnapshot(snapshot) + data["extra_headers"] = headers + bound.arguments["data"] = data + if text and bound.arguments.get("logging_obj") is not None: + bound.arguments["logging_obj"] = _SDKHeaderLoggingProxy(bound.arguments["logging_obj"]) + # Capture before returning a lazy text streaming generator, not at first iteration. + return original(*bound.args, **bound.kwargs) + + scoped._pr_agent_sdk_headers_original = original + scoped._pr_agent_sdk_headers_context = _sdk_request_headers + return scoped + + +def _install_sdk_header_bridge(): + """Adapt the locked SDK's request boundary without mutating shared clients.""" + try: + from litellm.llms.azure.azure import AzureChatCompletion + from litellm.llms.azure.completion.handler import AzureTextCompletion + from litellm.llms.openai.completion.handler import OpenAITextCompletion + from litellm.llms.openai.openai import OpenAIChatCompletion + from openai._base_client import BaseClient + except ImportError as error: + raise RuntimeError("The installed LiteLLM/OpenAI SDK cannot isolate request headers") from error + + bindings = ( + (OpenAIChatCompletion, "make_openai_chat_completion_request", False, False), + (AzureChatCompletion, "make_azure_openai_chat_completion_request", False, False), + (OpenAITextCompletion, "acompletion", True, False), + (OpenAITextCompletion, "async_streaming", True, False), + (AzureTextCompletion, "acompletion", True, False), + (AzureTextCompletion, "async_streaming", True, False), + (OpenAIChatCompletion, "completion", False, True), + ) + _require_litellm_interface(BaseClient, "OpenAI.BaseClient", ("_build_headers",)) + for owner, name, _, copilot in bindings: + _require_litellm_interface(owner, owner.__name__, (name,)) + required = {"optional_params", "headers"} if copilot else {"data", "logging_obj"} + if not required <= inspect.signature(getattr(owner, name)).parameters.keys(): + raise RuntimeError("The installed LiteLLM SDK request interface cannot isolate headers") + + current = BaseClient._build_headers + if getattr(current, "_pr_agent_sdk_headers_context", None) is not _sdk_request_headers: + sdk_build_headers = getattr(current, "_pr_agent_sdk_headers_original", current) + + @wraps(sdk_build_headers) + def build_headers(client, options, *, retries_taken=0): + headers = dict(options.headers or {}) + marker = headers.get(_SDK_HEADER_MARKER) + if not isinstance(marker, _SDKHeaderSnapshot): + return sdk_build_headers(client, options, retries_taken=retries_taken) + if type(client) not in (openai.OpenAI, openai.AsyncOpenAI, openai.AzureOpenAI, openai.AsyncAzureOpenAI): + raise ValueError("Custom SDK clients are incompatible with request header isolation") + del headers[_SDK_HEADER_MARKER] + snapshot = marker.snapshot + view = copy.copy(client) + view._custom_headers = {} + view.organization = snapshot["organization"] + view.project = snapshot["project"] + explicit = dict(snapshot["explicit_headers"]) + for layer in (headers, explicit): + for name, value in tuple(layer.items()): + if isinstance(value, _CapturedSDKHeader): + # Captured SDK defaults already provide the account identity; + # internal guards must not override native custom headers. + del layer[name] + native = {**view._auth_headers(options.security), **view.default_headers} + merged = _merge_sdk_headers(native, snapshot["custom_headers"], headers, explicit) + # Native defaults retain their canonical spelling; options override them without duplicates. + request_options = options.model_copy(update={"headers": merged}) + result = sdk_build_headers(view, request_options, retries_taken=retries_taken) + if _SDK_HEADER_MARKER in result: + raise RuntimeError("SDK isolation marker reached HTTP headers") + return result + + build_headers._pr_agent_sdk_headers_original = sdk_build_headers + build_headers._pr_agent_sdk_headers_context = _sdk_request_headers + BaseClient._build_headers = build_headers + for owner, name, text, copilot in bindings: + current = getattr(owner, name) + if getattr(current, "_pr_agent_sdk_headers_context", None) is not _sdk_request_headers: + original = getattr(current, "_pr_agent_sdk_headers_original", current) + setattr(owner, name, _sdk_data_adapter(original, text=text, copilot=copilot)) + + +def _azure_ai_native_transport(provider: str, model: str | None) -> tuple[str, str | None]: + """Follow Azure AI's native model classification without resolving credentials.""" + if provider == "azure_ai" and isinstance(model, str): + from litellm.llms.azure_ai.chat.transformation import AzureAIStudioConfig + + native_model = model.removeprefix("azure_ai/") + if AzureAIStudioConfig()._is_azure_openai_model(native_model, None): + return "azure", native_model + return provider, model + + +def _is_cloudflare_gateway(api_base: str | None) -> bool: + """Match the AI Gateway host itself; a substring test also matches it inside a path or query.""" + hostname = urlparse(api_base or "").hostname or "" + return hostname == "gateway.ai.cloudflare.com" or hostname.endswith(".gateway.ai.cloudflare.com") + + +def _request_local_openai_headers(provider: str, organization=None, model: str | None = None) -> dict | None: + """Block LiteLLM's process-wide OpenAI headers for one request.""" + transport_provider, model = _azure_ai_native_transport(provider, model) + provider = PROVIDER_SETTING_ALIASES.get(transport_provider, transport_provider) + if not ( + provider in ("azure", "openai", "openrouter") + or provider in OPENAI_COMPATIBLE_REQUEST_PROVIDERS + or provider in MANAGED_AUTH_REQUEST_PROVIDERS + or _is_openai_compatible_request_provider(provider) + ): + return None + if _uses_openai_responses_transport(model, transport_provider): + if provider == "openai" and organization: + return {"OpenAI-Organization": organization} + return None + experimental_raw_http_handler = os.environ.get( + "EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER", "" + ).strip().lower() == "true" + uses_raw_http_handler = transport_provider in OPENAI_RAW_HTTP_REQUEST_PROVIDERS or ( + experimental_raw_http_handler + and not _uses_openai_text_completion_transport(model, transport_provider) + and ( + transport_provider == "openai" + or transport_provider in OPENAI_COMPATIBLE_REQUEST_PROVIDERS + or transport_provider in MANAGED_AUTH_REQUEST_PROVIDERS + or _is_openai_compatible_request_provider(transport_provider) + ) + ) + if uses_raw_http_handler: + return None + request_organization = organization if provider == "openai" and organization else _CapturedSDKHeader() + return { + "OpenAI-Organization": request_organization, + "OpenAI-Project": _CapturedSDKHeader(), + } + + +def _vertex_project_from_environment(): + # Google Auth treats an empty GOOGLE_CLOUD_PROJECT as masking GCLOUD_PROJECT. + return os.environ.get("VERTEXAI_PROJECT") or os.environ.get( + "GOOGLE_CLOUD_PROJECT", os.environ.get("GCLOUD_PROJECT"), + ) + + +def _get_bedrock_model_region(model: str, model_id=None) -> str | None: + """Resolve only the region carried by the request, without ambient AWS discovery.""" + from litellm.llms.bedrock.common_utils import BedrockModelInfo + + # LiteLLM 1.101.0 consumes model_id before Invoke resolves its region. + # Only Converse uses that separate ID or region/model path for routing. + is_converse = BedrockModelInfo.get_bedrock_route(model) == "converse" + if not is_converse: + model_id = None + candidate = model_id or model + if not isinstance(candidate, str): + return None + if not model_id: + for prefix in ("bedrock/converse/", "bedrock/", "converse/"): + if candidate.startswith(prefix): + candidate = candidate[len(prefix):] + break + arn_candidate = candidate + if not model_id: + arn_candidate = arn_candidate.removeprefix("invoke/") + # Match the native chat model prefixes in LiteLLM 1.101.0 without + # decoding an ARN or changing Converse's region/model path grammar. + for prefix in ("llama/", "deepseek_r1/", "openai/", "qwen2/", "qwen3/", "moonshot/", "nova-2/", "nova/"): + if arn_candidate.startswith(prefix): + arn_candidate = arn_candidate[len(prefix):] + break + arn = re.match(r"\Aarn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):", arn_candidate) + if arn: + return arn.group(1) + # Native Converse only recognizes region/model paths when model_id is absent. + if is_converse and not model_id: + region, separator, _ = candidate.partition("/") + if separator and region in litellm.AmazonBedrockGlobalConfig().get_all_regions(): + return region + return None + + +def _guard_request_routing_globals(provider: str | None, params: dict) -> dict: + """Reject process-wide LiteLLM routing fallbacks for one request.""" + if getattr(litellm, "api_base", None) and ( + "api_base" not in params or provider in LITELLM_GLOBAL_FIRST_API_BASE_PROVIDERS + ): + raise ValueError(f"Refusing process-wide LiteLLM API base fallback for provider {provider or 'unknown'}") + if provider == "gdc" and not params.get("api_base") and getattr(litellm, "gdc_api_base", None): + raise ValueError("Refusing process-wide LiteLLM API base fallback for provider gdc") + if provider == "azure" and "api_version" not in params and getattr(litellm, "api_version", None): + raise ValueError("Refusing process-wide LiteLLM API version fallback for provider azure") + organization_is_guarded = any( + header.lower() == "openai-organization" + for header in (params.get("headers") or {}) + ) + if ( + provider == "openai" + and "organization" not in params + and not organization_is_guarded + and getattr(litellm, "organization", None) + ): + raise ValueError("Refusing process-wide LiteLLM organization fallback for provider openai") + # GDC's complete project URL owns its routing; host-only URLs otherwise + # inherit the same process-wide project/location fallbacks as Vertex. + if provider == "vertex_ai" or (provider == "gdc" and "/v1/projects/" not in (params.get("api_base") or "")): + for parameter, global_name in ( + ("vertex_project", "vertex_project"), + ("vertex_location", "vertex_location"), + ): + if parameter not in params and getattr(litellm, global_name, None): + raise ValueError(f"Refusing process-wide LiteLLM {parameter} fallback for provider {provider}") + routing_environment_variables = dict(PROVIDER_ROUTING_ENV_VARS.get(provider, {})) + api_base_environment_variables = list(PROVIDER_API_BASE_ENV_VARS.get(provider, ())) + provider_config = _resolve_provider_registry().get(provider) + api_base_env = getattr(provider_config, "api_base_env", None) + if api_base_env: + api_base_environment_variables.append(api_base_env) + if api_base_environment_variables: + routing_environment_variables.setdefault("api_base", tuple(api_base_environment_variables)) + for parameter, environment_variables in routing_environment_variables.items(): + live_value = next( + (os.environ.get(variable) for variable in environment_variables if os.environ.get(variable)), + None, + ) + if provider == "vertex_ai" and parameter == "vertex_project": + live_value = _vertex_project_from_environment() + if parameter not in params and live_value: + raise ValueError(f"Refusing live {parameter} environment fallback for provider {provider}") + if ( + provider == "chatgpt" + and parameter == "api_base" + and parameter in params + and live_value != params[parameter] + ): + # LiteLLM 1.101.0's ChatGPT chat transformation ignores the request + # api_base and re-reads these variables when resolving provider info. + raise ValueError("Refusing changed live api_base environment for provider chatgpt") + return params + + +def _has_provider_api_key_global(provider: str) -> bool: + """Return whether LiteLLM retains a process-wide key for this provider.""" + return any(getattr(litellm, name, None) for name in PROVIDER_API_KEY_GLOBALS.get(provider, ())) + + +def _has_live_provider_api_key_environment(provider: str) -> bool: + """Return whether a provider key is currently available in the environment.""" + environment_variables = PROVIDER_API_KEY_ENV_VARS.get(provider, ()) + if not environment_variables: + provider_config = _resolve_provider_registry().get(provider) + api_key_env = getattr(provider_config, "api_key_env", None) + environment_variables = (api_key_env,) if api_key_env else () + return any(os.environ.get(environment_variable) for environment_variable in environment_variables) + + +AWS_REQUEST_CREDENTIAL_KEYS = ( + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "aws_region_name", +) + +LITELLM_AWS_CREDENTIAL_SELECTOR_ENV_VARS = ( + "AWS_PROFILE_NAME", + "AWS_ROLE_NAME", +) + +AWS_REQUEST_ENDPOINT_ENV_VARS = ( + "AWS_ENDPOINT_URL", + "AWS_ENDPOINT_URL_BEDROCK_RUNTIME", + "AWS_ENDPOINT_URL_SAGEMAKER_RUNTIME", +) + +AWS_CREDENTIAL_CHAIN_ENV_VARS = ( + *LITELLM_AWS_CREDENTIAL_SELECTOR_ENV_VARS, + "AWS_PROFILE", + "AWS_DEFAULT_PROFILE", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + "BOTO_CONFIG", + "AWS_CREDENTIAL_FILE", + "AWS_ROLE_ARN", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_ROLE_SESSION_NAME", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", + "AWS_EC2_METADATA_DISABLED", + "AWS_EC2_METADATA_SERVICE_ENDPOINT", + "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE", + "AWS_EC2_METADATA_V1_DISABLED", + "AWS_IMDS_USE_IPV6", + *AWS_REQUEST_ENDPOINT_ENV_VARS, + "AWS_ENDPOINT_URL_STS", + "AWS_STS_REGIONAL_ENDPOINTS", + "AWS_SECURITY_TOKEN", + "AWS_REGION_NAME", + "AWS_REGION", + "AWS_DEFAULT_REGION", +) + +BEDROCK_MANTLE_REQUEST_CONTEXT_KEYS = ( + *AWS_REQUEST_CREDENTIAL_KEYS, + "aws_bedrock_runtime_endpoint", +) + +BEDROCK_MANTLE_REQUEST_BODY_EXCLUDED_KEYS = BEDROCK_MANTLE_REQUEST_CONTEXT_KEYS + +AWS_REQUEST_PROVIDERS = { + "bedrock", + "bedrock_mantle", + "sagemaker", + "sagemaker_chat", + "sagemaker_nova", +} + +LITELLM_GLOBAL_FIRST_API_BASE_PROVIDERS = { + "custom", + "gradient_ai", + "ollama", + "ollama_chat", + "triton", +} + +_bedrock_mantle_request_credentials = ContextVar("bedrock_mantle_request_credentials", default=None) +_bedrock_mantle_block_bearer = ContextVar("bedrock_mantle_block_bearer", default=False) +_BEDROCK_MANTLE_ORIGINAL_SIGNER = "_pr_agent_original_sign_request" +_BEDROCK_MANTLE_ORIGINAL_TOKEN_RESOLVER = "_pr_agent_original_resolve_bearer_token" +_bedrock_mantle_sign_request = None +_bedrock_mantle_resolve_bearer_token = None +if BedrockMantleAuthMixin is not None: + _bedrock_mantle_sign_request = getattr( + BedrockMantleAuthMixin.sign_request, + _BEDROCK_MANTLE_ORIGINAL_SIGNER, + BedrockMantleAuthMixin.sign_request, + ) + _bedrock_mantle_resolve_bearer_token = getattr( + BedrockMantleAuthMixin._resolve_bearer_token, + _BEDROCK_MANTLE_ORIGINAL_TOKEN_RESOLVER, + BedrockMantleAuthMixin._resolve_bearer_token, + ) + +_vertex_request_credentials = ContextVar("vertex_request_credentials", default=None) +_vertex_request_active = ContextVar("vertex_request_active", default=False) +_vertex_request_aws_environment = ContextVar("vertex_request_aws_environment", default=None) +_vertex_request_default_adc = ContextVar("vertex_request_default_adc", default=None) +_databricks_request_keyless = ContextVar("databricks_request_keyless", default=False) +_raw_api_key_guard_provider = ContextVar("raw_api_key_guard_provider", default=None) +_raw_api_key_guard_auth = ContextVar("raw_api_key_guard_auth", default=None) + + +def _raw_guard_has_header_only_auth(provider, params, headers): + """Recognize only initial native header-only authentication choices.""" + snapshot = _raw_api_key_guard_auth.get() + if not snapshot or snapshot["generic_key"]: + return False + if provider == "ragflow": + return True + if provider == "xai": + return not snapshot["xai_key"] and not params.get("use_xai_oauth") + if provider != "azure_ai" or any(name.lower() == "api-key" for name in headers): + return False + if ( + snapshot["azure_key"] or snapshot["azure_ad_token"] or snapshot["azure_refresh"] + or params.get("azure_ad_token") or params.get("azure_ad_token_provider") is not None + ): + return False + environment = snapshot["azure_environment"] + tenant = params.get("tenant_id") or environment["AZURE_TENANT_ID"] + client = params.get("client_id") or environment["AZURE_CLIENT_ID"] + secret = params.get("client_secret") or environment["AZURE_CLIENT_SECRET"] + username = params.get("azure_username") or environment["AZURE_USERNAME"] + password = params.get("azure_password") or environment["AZURE_PASSWORD"] + return not ((tenant and client and secret) or (username and password and client)) + + +def _require_http_auth_signature(original): + """Reject a native HTTP validator that no longer exposes the request auth parameters.""" + signature = inspect.signature(original) + if not {"api_key", "headers", "litellm_params"}.issubset(signature.parameters): + raise RuntimeError("LiteLLM's native HTTP authentication interface is incompatible") + return signature + + +def _install_raw_api_key_guard_override_bridge(provider): + """Undo generated auth headers without reopening a native key resolver.""" + if provider not in ("azure_ai", "ragflow", "xai"): + return + from litellm.llms.azure_ai.chat.transformation import AzureAIStudioConfig + from litellm.llms.ragflow.chat.transformation import RAGFlowConfig + from litellm.llms.xai.chat.transformation import XAIChatConfig + + config = {"azure_ai": AzureAIStudioConfig, "ragflow": RAGFlowConfig, "xai": XAIChatConfig}[provider] + original = config.validate_environment + marker = "_pr_agent_original_raw_override_validate_environment" + if getattr(original, marker, None) is not None: + return + signature = _require_http_auth_signature(original) + + def validate_environment(self, *args, **kwargs): + if _raw_api_key_guard_provider.get() != provider or type(self) is not config: + return original(self, *args, **kwargs) + bound = signature.bind(self, *args, **kwargs) + headers = bound.arguments.get("headers") or {} + native_params = bound.arguments.get("litellm_params") or {} + companion = _azure_ad_responses_request.get() + if ( + provider == "azure_ai" and companion is not None and companion.get("companion") + and companion["generated_guard"] + and bound.arguments.get("api_key") == DUMMY_LITELLM_API_KEY + and native_params.get("custom_llm_provider") == provider + and native_params.get("azure_ad_token") == companion["dispatch_token"] + ): + # The outer validator selects AD only without a key. Keep the guard + # in the inner validator, whose captured resolver bypasses fallback. + bound.arguments["headers"] = dict(headers) + bound.arguments["api_key"] = None + bound.arguments["litellm_params"] = {**native_params, "api_key": DUMMY_LITELLM_API_KEY} + return original(*bound.args, **bound.kwargs) + authorization = [name for name in headers if name.lower() == "authorization"] + if ( + bound.arguments.get("api_key") != DUMMY_LITELLM_API_KEY + or native_params.get("custom_llm_provider") != provider + or len(authorization) != 1 + or not _raw_guard_has_header_only_auth(provider, native_params, headers) + ): + return original(self, *args, **kwargs) + name = authorization[0] + value = headers[name] + bound.arguments["headers"] = dict(headers) + # Keep the guard during validation, including nested native resolvers. + # Preserve non-auth effects such as Ragflow's model extraction. + result = original(*bound.args, **bound.kwargs) + for header in list(result): + if header.lower() == "authorization" or (provider == "azure_ai" and header.lower() == "api-key"): + del result[header] + result[name] = value + return result + + setattr(validate_environment, marker, original) + config.validate_environment = validate_environment + + +def _install_raw_api_key_guard_bridge(): + """Keep generated fallback guards from replacing explicit native HTTP auth.""" + from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig + + original = OpenAIGPTConfig.validate_environment + marker = "_pr_agent_original_raw_validate_environment" + if getattr(original, marker, None) is not None: + return + signature = _require_http_auth_signature(original) + + def validate_environment(self, *args, **kwargs): + provider = _raw_api_key_guard_provider.get() + # Overrides that call super() may have their own authentication policy. + if provider is None or type(self).validate_environment is not validate_environment: + return original(self, *args, **kwargs) + bound = signature.bind(self, *args, **kwargs) + headers = bound.arguments.get("headers") or {} + native_params = bound.arguments.get("litellm_params") or {} + if ( + native_params.get("custom_llm_provider") == provider + and bound.arguments.get("api_key") == DUMMY_LITELLM_API_KEY + and any(name.lower() == "authorization" for name in headers) + ): + # Only this pure validator sees None. Keep the guard in dispatch + # kwargs so other native consumers cannot discover a foreign key. + bound.arguments["api_key"] = None + return original(*bound.args, **bound.kwargs) + + setattr(validate_environment, marker, original) + OpenAIGPTConfig.validate_environment = validate_environment + + +def _install_databricks_keyless_bridge(): + """Restore native auth only for a placeholder generated by the active handler.""" + try: + from litellm.llms.databricks.common_utils import DatabricksBase + except ImportError as error: + raise RuntimeError("LiteLLM's Databricks authentication interface is unavailable") from error + original = getattr(DatabricksBase, "databricks_validate_environment", None) + marker = "_pr_agent_original_databricks_validate_environment" + if getattr(original, marker, None) is not None: + return + try: + signature = inspect.signature(original) + parameter = signature.parameters["api_key"] + if parameter.kind not in (parameter.POSITIONAL_OR_KEYWORD, parameter.KEYWORD_ONLY): + raise ValueError("Unsupported api_key parameter") + except (KeyError, TypeError, ValueError) as error: + raise RuntimeError("LiteLLM's Databricks authentication interface is incompatible") from error + + def validate_environment(self, *args, **kwargs): + if not _databricks_request_keyless.get(): + return original(self, *args, **kwargs) + bound = signature.bind(self, *args, **kwargs) + if bound.arguments.get("api_key") == DUMMY_LITELLM_API_KEY: + bound.arguments["api_key"] = None + # Keep native OAuth/SDK authentication; do not let the global-key guard + # overwrite its Authorization header. Their deployment configuration + # is not a request-level PR-Agent credential setting. + return original(*bound.args, **bound.kwargs) + + setattr(validate_environment, marker, original) + DatabricksBase.databricks_validate_environment = validate_environment + + +def _snapshot_cloud_sdk_project(directory): + """Capture Cloud SDK 583's project property without running a subprocess.""" + if "CLOUDSDK_CORE_PROJECT" in os.environ: + return os.environ["CLOUDSDK_CORE_PROJECT"].strip() or None + paths = [] + executable = shutil.which("gcloud.cmd" if os.name == "nt" else "gcloud") + if executable: + sdk_root = os.path.dirname(os.path.dirname(os.path.realpath(executable))) + if os.path.isdir(os.path.join(sdk_root, ".install")): + paths.append(os.path.join(sdk_root, "properties")) + configuration = os.environ.get("CLOUDSDK_ACTIVE_CONFIG_NAME") + migrate_legacy = False + if not configuration: + try: + with open(os.path.join(directory, "active_config"), encoding="utf-8") as active: + configuration = active.read() + except FileNotFoundError: + configuration = None + migrate_legacy = True + if configuration and configuration != "NONE" and not re.match(r"^[a-z][-a-z0-9]*$", configuration): + # The SDK removes an invalid activator before checking legacy files. + migrate_legacy = True + configuration = None + if not configuration: + legacy_path = os.path.join(directory, "properties") + legacy_contents = "" + if migrate_legacy: + try: + with open(legacy_path, encoding="utf-8") as legacy: + legacy_contents = legacy.read() + except FileNotFoundError: + # No legacy properties file: fall back to the default named config below. + pass + deprecated = ( + "# This properties file has been superseded by named configurations.\n" + "# Editing it will have no effect.\n\n" + ) + if legacy_contents and not legacy_contents.startswith(deprecated): + paths.append(legacy_path) + else: + paths.append(os.path.join(directory, "configurations", "config_default")) + elif configuration != "NONE": + paths.append(os.path.join(directory, "configurations", f"config_{configuration}")) + # Match the SDK's terminal-newline semantics without trimming the filename. + if configuration and configuration != "NONE" and not re.match(r"^[a-z][-a-z0-9]*$", configuration): + raise ValueError("Invalid Cloud SDK configuration name") + project = None + for path in paths: + parser = configparser.ConfigParser() + try: + with open(path, encoding="utf-8") as config: + parser.read_file(config) + except FileNotFoundError: + continue + project = parser.get("core", "project", fallback=project) + return (project.strip() or None) if project is not None else None + + +def _load_vertex_default_adc(snapshot, project_id): + """Load only the captured ADC source, retaining native credential refresh.""" + from google.auth import _default, exceptions + from google.auth import credentials as google_credentials + from google.auth.transport.requests import Request + + if snapshot["error"]: + raise ValueError(f"Unable to snapshot default Vertex credentials: {snapshot['error']}") + info = snapshot["info"] + scopes = ["https://www.googleapis.com/auth/cloud-platform"] + credential_project = None + if info is None: + # Skip file-based discovery permanently when no file was captured. + # Retain google-auth 2.55.1's managed-runtime discovery and refresh. + for name in ("_get_gae_credentials", "_get_gce_credentials"): + if not callable(getattr(_default, name, None)): + raise RuntimeError("Google Auth managed ADC interface is unavailable") + loaded, credential_project = (None, None) + if snapshot["gae_runtime"] == "python27": + loaded, credential_project = _default._get_gae_credentials() + if loaded is None: + loaded, credential_project = _default._get_gce_credentials(request=Request()) + if loaded is None: + raise exceptions.DefaultCredentialsError("No captured file or managed-runtime Vertex credentials available") + else: + credential_type = info.get("type") + if credential_type == "authorized_user": + from google.oauth2.credentials import Credentials + + loaded = Credentials.from_authorized_user_info(info, scopes=scopes) + elif credential_type == "service_account": + from google.oauth2.service_account import Credentials + + loaded = Credentials.from_service_account_info(info, scopes=scopes) + credential_project = loaded.project_id + elif credential_type == "impersonated_service_account": + from google.auth.impersonated_credentials import Credentials + + loaded = Credentials.from_impersonated_service_account_info(info, scopes=scopes) + elif credential_type == "external_account_authorized_user": + from google.auth.external_account_authorized_user import Credentials + + loaded = Credentials.from_info(info) + elif credential_type == "gdch_service_account": + from google.oauth2.gdch_credentials import ServiceAccountCredentials + + loaded = ServiceAccountCredentials.from_service_account_info(info) + credential_project = info.get("project") + elif credential_type == "external_account": + source = info.get("credential_source", {}) + if not isinstance(source, dict): + raise ValueError("Invalid Vertex credential source") + if "executable" in source: + raise ValueError("Vertex executable credentials are incompatible with request isolation") + if info.get("subject_token_type") == "urn:ietf:params:aws:token-type:aws4_request": + loaded = _vertex_aws_credentials_from_snapshot(info, snapshot["aws_environment"], scopes) + else: + from google.auth.identity_pool import Credentials + + loaded = Credentials.from_info(info, scopes=scopes) + else: + raise ValueError("Unsupported default Vertex credential type") + loaded = google_credentials.with_scopes_if_required(loaded, scopes) + quota_project = snapshot["quota_project"] + if isinstance(loaded, google_credentials.CredentialsWithQuotaProject): + # Apply only the captured environment override; never consult a later one. + if quota_project or info is None: + loaded = loaded.with_quota_project(quota_project) + if ( + info is not None and info.get("type") == "external_account" + and not project_id and not snapshot["sdk_project"] + ): + credential_project = loaded.get_project_id(request=Request()) + resolved_project = project_id or credential_project or snapshot["sdk_project"] + if not resolved_project: + if snapshot["sdk_project_error"]: + raise ValueError(f"Unable to snapshot Cloud SDK project: {snapshot['sdk_project_error']}") + raise ValueError("Could not resolve project_id") + if not isinstance(resolved_project, str): + raise TypeError("Expected project_id to be a str") + return loaded, resolved_project + + +def _install_vertex_default_adc_bridge(): + """Recognize handler-owned ADC cache keys without changing unrelated LiteLLM callers.""" + from litellm.llms.vertex_ai.vertex_llm_base import VertexBase + + original = VertexBase.load_auth + marker = "_pr_agent_original_default_adc_load_auth" + if getattr(original, marker, None) is not None: + return + + def load_auth(self, credentials, project_id): + snapshot = _vertex_request_default_adc.get() + if snapshot is None or credentials != snapshot["cache_key"]: + return original(self, credentials, project_id) + load_default_adc = _handler_attr("_load_vertex_default_adc", _load_vertex_default_adc) + loaded, resolved_project = load_default_adc(snapshot, project_id) + self.refresh_auth(loaded) + return loaded, resolved_project + + load_auth.__dict__.update(vars(original)) + setattr(load_auth, marker, original) + VertexBase.load_auth = load_auth + + +def _vertex_aws_credentials_from_snapshot(info, environment, scopes): + """Bind the native AWS WIF source without replacing Google's signer or refresh flow.""" + from google.auth import aws, exceptions + + environment = dict(environment) + source = dict(info["credential_source"]) + + class SnapshotSupplier(aws.AwsSecurityCredentialsSupplier): + def _metadata(self, request, url, *, method="GET", headers=None): + if not url: + raise exceptions.RefreshError("Missing AWS WIF metadata endpoint") + response = request(url=url, method=method, headers=headers) + if response.status != 200: + raise exceptions.RefreshError("Unable to retrieve AWS WIF metadata") + return response.data.decode("utf-8") if isinstance(response.data, bytes) else response.data + + def _metadata_headers(self, request): + token_url = source.get("imdsv2_session_token_url") + if token_url is None: + return None + token = self._metadata( + request, token_url, method="PUT", headers={"X-aws-ec2-metadata-token-ttl-seconds": "300"}, + ) + return {"X-aws-ec2-metadata-token": token} + + def get_aws_security_credentials(self, context, request): + access_key = environment.get("AWS_ACCESS_KEY_ID") + secret_key = environment.get("AWS_SECRET_ACCESS_KEY") + if access_key and secret_key: + return aws.AwsSecurityCredentials(access_key, secret_key, environment.get("AWS_SESSION_TOKEN")) + headers = self._metadata_headers(request) + url = source.get("url") + role = self._metadata(request, url, headers=headers) + credentials = json.loads(self._metadata(request, f"{url}/{role}", headers=headers)) + return aws.AwsSecurityCredentials( + credentials.get("AccessKeyId"), credentials.get("SecretAccessKey"), credentials.get("Token"), + ) + + def get_aws_region(self, context, request): + for variable in ("AWS_REGION", "AWS_DEFAULT_REGION"): + region = environment.get(variable) + if region is not None: + return region + return self._metadata(request, source.get("region_url"), headers=self._metadata_headers(request))[:-1] + + supplier = SnapshotSupplier() + + class SnapshotCredentials(aws.Credentials): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # google-auth 2.55.1 cannot combine a supplier with credential_source + # through its public constructor. Preserve native validation and the + # verification URL; reapply the supplier on copies and impersonation. + if not isinstance( + getattr(self, "_aws_security_credentials_supplier", None), aws.AwsSecurityCredentialsSupplier, + ): + raise RuntimeError("Google Auth AWS WIF supplier interface is unavailable") + self._aws_security_credentials_supplier = supplier + + return SnapshotCredentials.from_info(info, scopes=scopes) + + +def _install_vertex_executable_guard(): + """Reject executable ADC without replacing LiteLLM's native token cache.""" + from google.auth import pluggable + + credentials = pluggable.Credentials + marker = "_pr_agent_original_vertex_executable" + replacements = {} + + def guard(original): + def guarded(*args, **kwargs): + if _vertex_request_active.get(): + raise ValueError("Vertex executable credentials are incompatible with request isolation") + return original(*args, **kwargs) + + setattr(guarded, marker, original) + return guarded + + # LiteLLM 1.101.0 reads expired (sync) or token_state (async) on the + # actual cached credential before returning its token. Guarding the + # descriptors also covers cache replacement during an async lock wait. + for name in ("from_info", "refresh", "expired", "token_state"): + descriptor = inspect.getattr_static(credentials, name, None) + if isinstance(descriptor, classmethod): + original = descriptor.__func__ + elif isinstance(descriptor, property): + original = descriptor.fget + else: + original = descriptor + _require_litellm_interface(original, f"Google executable credentials {name}") + if getattr(original, marker, None) is not None: + continue + wrapped = guard(original) + if isinstance(descriptor, classmethod): + wrapped = classmethod(wrapped) + elif isinstance(descriptor, property): + wrapped = property(wrapped, descriptor.fset, descriptor.fdel, descriptor.__doc__) + replacements[name] = wrapped + + # Validate every entry point before installing any of the guards. + for name, replacement in replacements.items(): + setattr(credentials, name, replacement) + + +def _install_vertex_wif_project_bridge(): + """Restore ADC project discovery for request-owned WIF JSON in LiteLLM 1.101.0.""" + from litellm.llms.vertex_ai.vertex_llm_base import VertexBase + + original = VertexBase.load_auth + marker = "_pr_agent_original_vertex_wif_load_auth" + if getattr(original, marker, None) is not None: + return + + def load_auth(self, credentials, project_id): + snapshot = _vertex_request_credentials.get() + if not isinstance(snapshot, dict) or snapshot.get("type") != "external_account": + return original(self, credentials, project_id) + try: + json_obj = json.loads(credentials) if isinstance(credentials, str) else credentials + except (ValueError, TypeError): + return original(self, credentials, project_id) + if json_obj != snapshot: + return original(self, credentials, project_id) + + # Preserve LiteLLM 1.101.0's WIF factories, including its explicit AWS + # supplier. Only project discovery is missing from its JSON load path. + scopes = ["https://www.googleapis.com/auth/cloud-platform"] + source = json_obj.get("credential_source", {}) + environment_id = source.get("environment_id", "") if isinstance(source, dict) else "" + aws_environment = _vertex_request_aws_environment.get() + bind_aws = isinstance(environment_id, str) and "aws" in environment_id and aws_environment is not None + if project_id is not None and not bind_aws: + return original(self, credentials, project_id) + if isinstance(environment_id, str) and "aws" in environment_id: + from litellm.llms.vertex_ai.vertex_ai_aws_wif import VertexAIAwsWifAuth + + aws_params = VertexAIAwsWifAuth.extract_aws_params(json_obj) + if aws_params: + loaded = VertexAIAwsWifAuth.credentials_from_explicit_aws(json_obj, aws_params, scopes) + elif bind_aws: + loaded = _vertex_aws_credentials_from_snapshot(json_obj, aws_environment, scopes) + else: + loaded = self._credentials_from_identity_pool_with_aws(json_obj, scopes) + elif isinstance(source, dict) and "executable" in source: + raise ValueError("Vertex executable credentials are incompatible with request isolation") + else: + loaded = self._credentials_from_identity_pool(json_obj, scopes) + + from google.auth.transport.requests import Request + + # Use the same credential for discovery, refresh, and the upstream cache; + # never reload ADC or apply a process-wide quota project override here. + resolved_project = project_id if project_id is not None else loaded.get_project_id(request=Request()) + self.refresh_auth(loaded) + if not resolved_project: + raise ValueError("Could not resolve project_id") + if not isinstance(resolved_project, str): + raise TypeError(f"Expected project_id to be a str but got {type(resolved_project)}") + return loaded, resolved_project + + load_auth.__dict__.update(vars(original)) + setattr(load_auth, marker, original) + VertexBase.load_auth = load_auth + + +def _install_vertex_impersonated_credentials_bridge(): + """Add LiteLLM 1.101.0's missing ADC type without replacing its refresh/cache path.""" + from litellm.llms.vertex_ai.vertex_llm_base import VertexBase + + original = VertexBase._credentials_from_service_account + marker = "_pr_agent_original_vertex_service_account_loader" + if getattr(original, marker, None) is not None: + return + + def load_credentials(self, json_obj, scopes): + request_credentials = _vertex_request_credentials.get() + if ( + isinstance(json_obj, dict) + and json_obj.get("type") == "impersonated_service_account" + and request_credentials is not None + and json_obj == request_credentials + ): + from google.auth import impersonated_credentials + + # The generic ADC loader may apply a live GOOGLE_CLOUD_QUOTA_PROJECT. + # This constructor uses only the captured configuration, including quota. + return impersonated_credentials.Credentials.from_impersonated_service_account_info( + json_obj, scopes=scopes, + ) + return original(self, json_obj, scopes) + + setattr(load_credentials, marker, original) + VertexBase._credentials_from_service_account = load_credentials + + +_anthropic_request_auth_token = ContextVar("anthropic_request_auth_token", default=None) +_ANTHROPIC_ORIGINAL_API_KEY_RESOLVER = "_pr_agent_original_get_api_key" +_ANTHROPIC_ORIGINAL_AUTH_TOKEN_RESOLVER = "_pr_agent_original_get_auth_token" +_anthropic_get_api_key = getattr(AnthropicModelInfo, "get_api_key", None) +_anthropic_get_api_key = getattr( + _anthropic_get_api_key, + _ANTHROPIC_ORIGINAL_API_KEY_RESOLVER, + _anthropic_get_api_key, +) +_anthropic_get_auth_token = getattr(AnthropicModelInfo, "get_auth_token", None) +_anthropic_get_auth_token = getattr( + _anthropic_get_auth_token, + _ANTHROPIC_ORIGINAL_AUTH_TOKEN_RESOLVER, + _anthropic_get_auth_token, +) + + +def _resolve_anthropic_api_key(api_key=None): + """Turn PR-Agent's request-local guard back into Anthropic's keyless state.""" + request_auth = _anthropic_request_auth_token.get() + if request_auth is not None and request_auth["generated_guard"] and api_key == DUMMY_LITELLM_API_KEY: + return None + return _anthropic_get_api_key(api_key) + + +def _resolve_anthropic_auth_token(auth_token=None): + """Resolve only the bearer token captured for the active Anthropic request.""" + request_auth = _anthropic_request_auth_token.get() + if request_auth is not None: + return request_auth["auth_token"] + return _anthropic_get_auth_token(auth_token) + + +def _install_anthropic_auth_token_bridge(): + """Install request-local Anthropic credential resolvers without stacking wrappers.""" + global _anthropic_get_api_key, _anthropic_get_auth_token + anthropic_model_info = _handler_attr("AnthropicModelInfo", AnthropicModelInfo) + _require_litellm_interface( + anthropic_model_info, "AnthropicModelInfo", ("get_api_key", "get_auth_token") + ) + current_api_key_resolver = anthropic_model_info.get_api_key + _anthropic_get_api_key = getattr( + current_api_key_resolver, + _ANTHROPIC_ORIGINAL_API_KEY_RESOLVER, + current_api_key_resolver, + ) + setattr(_resolve_anthropic_api_key, _ANTHROPIC_ORIGINAL_API_KEY_RESOLVER, _anthropic_get_api_key) + anthropic_model_info.get_api_key = staticmethod(_resolve_anthropic_api_key) + current_auth_token_resolver = anthropic_model_info.get_auth_token + _anthropic_get_auth_token = getattr( + current_auth_token_resolver, + _ANTHROPIC_ORIGINAL_AUTH_TOKEN_RESOLVER, + current_auth_token_resolver, + ) + setattr( + _resolve_anthropic_auth_token, + _ANTHROPIC_ORIGINAL_AUTH_TOKEN_RESOLVER, + _anthropic_get_auth_token, + ) + anthropic_model_info.get_auth_token = staticmethod(_resolve_anthropic_auth_token) + + +def _resolve_bedrock_mantle_bearer_token(api_key): + """Prevent LiteLLM's generic key fallback while preserving request-local SigV4.""" + if _bedrock_mantle_block_bearer.get(): + return None if api_key == DUMMY_LITELLM_API_KEY else "" + return _bedrock_mantle_resolve_bearer_token(api_key) + + +def _sign_bedrock_mantle_request(self, *args, **kwargs): + """Bridge request-local AWS credentials to LiteLLM's Bedrock Mantle signer.""" + bedrock_mantle_sign_request = _bedrock_mantle_sign_request + request_credentials = _bedrock_mantle_request_credentials.get() + if request_credentials is not None: + if "request_data" in kwargs: + request_data = kwargs["request_data"] + else: + try: + parameters = tuple(inspect.signature(bedrock_mantle_sign_request).parameters.values()) + request_data_position = next( + index - 1 + for index, parameter in enumerate(parameters) + if parameter.name == "request_data" + and parameter.kind in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD) + ) + except (StopIteration, TypeError, ValueError): + request_data_position = None + if request_data_position is None or len(args) <= request_data_position: + raise RuntimeError( + "LiteLLM's Bedrock Mantle signer did not receive request_data; " + "request-local AWS credentials cannot be removed from the request body" + ) + request_data = args[request_data_position] + if not isinstance(request_data, dict): + raise RuntimeError( + "LiteLLM's Bedrock Mantle signer received invalid request_data; " + "request-local AWS credentials cannot be removed from the request body" + ) + for key in BEDROCK_MANTLE_REQUEST_BODY_EXCLUDED_KEYS: + request_data.pop(key, None) + if _bedrock_mantle_block_bearer.get(): + if "api_key" in kwargs: + kwargs["api_key"] = "" + else: + try: + parameters = tuple(inspect.signature(bedrock_mantle_sign_request).parameters.values()) + api_key_parameter = next( + (index, parameter) + for index, parameter in enumerate(parameters) + if parameter.name == "api_key" + ) + except (StopIteration, TypeError, ValueError): + raise RuntimeError( + "LiteLLM's Bedrock Mantle signer did not expose api_key; " + "request-local bearer isolation cannot be applied" + ) + api_key_index, api_key_parameter = api_key_parameter + if api_key_parameter.kind == api_key_parameter.KEYWORD_ONLY: + kwargs["api_key"] = "" + elif api_key_parameter.kind in ( + api_key_parameter.POSITIONAL_ONLY, + api_key_parameter.POSITIONAL_OR_KEYWORD, + ): + api_key_position = api_key_index - 1 + if len(args) > api_key_position: + args = (*args[:api_key_position], "", *args[api_key_position + 1:]) + else: + kwargs["api_key"] = "" + else: + raise RuntimeError( + "LiteLLM's Bedrock Mantle signer has an unsupported api_key parameter; " + "request-local bearer isolation cannot be applied" + ) + if request_credentials: + if "optional_params" in kwargs: + kwargs["optional_params"] = {**(kwargs["optional_params"] or {}), **request_credentials} + else: + try: + parameters = tuple(inspect.signature(bedrock_mantle_sign_request).parameters.values()) + optional_params_position = next( + index - 1 + for index, parameter in enumerate(parameters) + if parameter.name == "optional_params" + and parameter.kind in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD) + ) + except (StopIteration, TypeError, ValueError): + optional_params_position = None + if optional_params_position is None or len(args) <= optional_params_position: + raise RuntimeError( + "LiteLLM's Bedrock Mantle signer did not receive optional_params; " + "request-local AWS credentials cannot be applied" + ) + args = ( + *args[:optional_params_position], + {**(args[optional_params_position] or {}), **request_credentials}, + *args[optional_params_position + 1:], + ) + return bedrock_mantle_sign_request(self, *args, **kwargs) + + +def _install_bedrock_mantle_signer_bridge(): + """Install the signer bridge without wrapping it again after a module reload.""" + global _bedrock_mantle_resolve_bearer_token, _bedrock_mantle_sign_request + if BedrockMantleAuthMixin is None: + return + current_signer = BedrockMantleAuthMixin.sign_request + _bedrock_mantle_sign_request = getattr( + current_signer, + _BEDROCK_MANTLE_ORIGINAL_SIGNER, + current_signer, + ) + setattr(_sign_bedrock_mantle_request, _BEDROCK_MANTLE_ORIGINAL_SIGNER, _bedrock_mantle_sign_request) + BedrockMantleAuthMixin.sign_request = _sign_bedrock_mantle_request + current_resolver = BedrockMantleAuthMixin._resolve_bearer_token + _bedrock_mantle_resolve_bearer_token = getattr( + current_resolver, + _BEDROCK_MANTLE_ORIGINAL_TOKEN_RESOLVER, + current_resolver, + ) + setattr( + _resolve_bedrock_mantle_bearer_token, + _BEDROCK_MANTLE_ORIGINAL_TOKEN_RESOLVER, + _bedrock_mantle_resolve_bearer_token, + ) + BedrockMantleAuthMixin._resolve_bearer_token = staticmethod(_resolve_bedrock_mantle_bearer_token) diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index 3d22f0dbf6..f4d6b5db41 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -1,8 +1,14 @@ import asyncio +import configparser import contextlib +import copy +import hashlib import json import os import re +import shutil # noqa: F401 (module attribute asserted by tests) +import stat +import threading import httpx import litellm @@ -11,17 +17,115 @@ from litellm import acompletion from tenacity import retry, retry_if_exception, stop_after_attempt +try: + from litellm.llms.anthropic.common_utils import AnthropicModelInfo +except ImportError: + AnthropicModelInfo = None + +try: + from litellm.llms.openai_like.json_loader import JSONProviderRegistry +except ImportError: + JSONProviderRegistry = None + +try: + from litellm.utils import _get_model_info_helper +except ImportError: + _get_model_info_helper = None + +try: + from litellm.llms.bedrock_mantle.common_utils import MANTLE_HOST_RE, BedrockMantleAuthMixin +except ImportError: + BedrockMantleAuthMixin = None + MANTLE_HOST_RE = None + from pr_agent.algo import ( CLAUDE_EXTENDED_THINKING_MODELS, GROK_REASONING_EFFORT_LEVELS, NO_SUPPORT_TEMPERATURE_MODELS, STREAMING_REQUIRED_MODELS, - SUPPORT_REASONING_EFFORT_MODELS, USER_MESSAGE_ONLY_MODELS, normalize_litellm_model, ) from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler +from pr_agent.algo.ai_handlers.cloud_auth import ( + _BEDROCK_MANTLE_ORIGINAL_SIGNER, # noqa: F401 (module attribute asserted by tests) + _BEDROCK_MANTLE_ORIGINAL_TOKEN_RESOLVER, # noqa: F401 (module attribute asserted by tests) + _SDK_HEADER_MARKER, # noqa: F401 (module attribute asserted by tests) + AWS_CREDENTIAL_CHAIN_ENV_VARS, + AWS_REQUEST_CREDENTIAL_KEYS, + AWS_REQUEST_ENDPOINT_ENV_VARS, + AWS_REQUEST_PROVIDERS, + AZURE_OIDC_ENV_VARS, + BEDROCK_MANTLE_REQUEST_BODY_EXCLUDED_KEYS, # noqa: F401 (module attribute asserted by tests) + BEDROCK_MANTLE_REQUEST_CONTEXT_KEYS, + DUMMY_LITELLM_API_KEY, + LITELLM_AWS_CREDENTIAL_SELECTOR_ENV_VARS, + LITELLM_GLOBAL_FIRST_API_BASE_PROVIDERS, # noqa: F401 (module attribute asserted by tests) + MANAGED_AUTH_REQUEST_PROVIDERS, + OPENAI_COMPATIBLE_REQUEST_PROVIDERS, + OPENAI_RAW_HTTP_REQUEST_PROVIDERS, # noqa: F401 (module attribute asserted by tests) + PROVIDER_API_BASE_ENV_VARS, + PROVIDER_API_KEY_ENV_VARS, + PROVIDER_API_KEY_GLOBALS, # noqa: F401 (module attribute asserted by tests) + PROVIDER_ROUTING_ENV_VARS, + PROVIDER_SETTING_ALIASES, + _anthropic_get_api_key, # noqa: F401 (module attribute asserted by tests) + _anthropic_get_auth_token, # noqa: F401 (module attribute asserted by tests) + _anthropic_request_auth_token, + _azure_ad_responses_request, + _azure_ai_native_transport, + _azure_oidc_entra_provider, # noqa: F401 (module attribute asserted by tests) + _azure_oidc_request, + _bedrock_mantle_block_bearer, + _bedrock_mantle_request_credentials, + _bedrock_mantle_resolve_bearer_token, # noqa: F401 (module attribute asserted by tests) + _bedrock_mantle_sign_request, # noqa: F401 (module attribute asserted by tests) + _CapturedSDKHeader, # noqa: F401 (module attribute asserted by tests) + _check_sdk_marker_collision, + _databricks_request_keyless, + _exchange_azure_oidc_token, + _get_bedrock_model_region, + _guard_request_routing_globals, + _has_live_provider_api_key_environment, + _has_provider_api_key_global, + _install_anthropic_auth_token_bridge, + _install_azure_oidc_bridge, + _install_bedrock_mantle_signer_bridge, + _install_databricks_keyless_bridge, + _install_raw_api_key_guard_bridge, + _install_raw_api_key_guard_override_bridge, + _install_sdk_header_bridge, + _install_vertex_default_adc_bridge, + _install_vertex_executable_guard, + _install_vertex_impersonated_credentials_bridge, + _install_vertex_wif_project_bridge, + _is_cloudflare_gateway, + _is_openai_compatible_request_provider, + _load_vertex_default_adc, # noqa: F401 (module attribute asserted by tests) + _merge_sdk_headers, + _raw_api_key_guard_auth, + _raw_api_key_guard_provider, + _raw_guard_has_header_only_auth, # noqa: F401 (module attribute asserted by tests) + _request_local_openai_headers, + _require_litellm_interface, + _resolve_bedrock_mantle_bearer_token, # noqa: F401 (module attribute asserted by tests) + _sdk_request_headers, + _SDKHeaderLoggingProxy, # noqa: F401 (module attribute asserted by tests) + _SDKHeaderSnapshot, # noqa: F401 (module attribute asserted by tests) + _sign_bedrock_mantle_request, # noqa: F401 (module attribute asserted by tests) + _snapshot_cloud_sdk_project, + _uses_openai_responses_transport, + _uses_openai_text_completion_transport, # noqa: F401 (module attribute asserted by tests) + _uses_provider_api_key, + _vertex_aws_credentials_from_snapshot, # noqa: F401 (module attribute asserted by tests) + _vertex_project_from_environment, + _vertex_request_active, + _vertex_request_aws_environment, + _vertex_request_credentials, + _vertex_request_default_adc, +) from pr_agent.algo.ai_handlers.litellm_helpers import ( + _get_azure_ad_credential, _get_azure_ad_token, _handle_streaming_response, _process_litellm_extra_body, @@ -29,13 +133,42 @@ get_repetition_penalty, ) from pr_agent.algo.run_details import _as_decimal_cost, record_ai_call -from pr_agent.algo.utils import ReasoningEffort, get_version +from pr_agent.algo.run_output import get_version +from pr_agent.algo.utils import ReasoningEffort from pr_agent.config_loader import get_settings, get_verbosity_level from pr_agent.log import get_logger MODEL_RETRIES = 2 -DUMMY_LITELLM_API_KEY = "dummy_key" # placeholder set when no OpenAI key is configured _IMAGE_HEAD_TIMEOUT_SECONDS = 5 +OPENAI_DEFAULT_API_BASE = "https://api.openai.com/v1" + +PROVIDER_SETTING_PATHS = { + "anthropic": {"api_key": "ANTHROPIC.KEY"}, + "codestral": {"api_key": "CODESTRAL.KEY"}, + "cohere": {"api_key": "COHERE.KEY"}, + "cohere_chat": {"api_key": "COHERE.KEY"}, + "dashscope": {"api_key": "DASHSCOPE.KEY"}, + "databricks": {"api_key": "DATABRICKS.API_KEY", "api_base": "DATABRICKS.API_BASE"}, + "deepinfra": {"api_key": "DEEPINFRA.KEY"}, + "deepseek": {"api_key": "DEEPSEEK.KEY"}, + "gemini": {"api_key": "GOOGLE_AI_STUDIO.GEMINI_API_KEY"}, + "groq": {"api_key": "GROQ.KEY"}, + "huggingface": {"api_key": "HUGGINGFACE.KEY", "api_base": "HUGGINGFACE.API_BASE"}, + "mistral": {"api_key": "MISTRAL.KEY"}, + "moonshot": {"api_key": "MOONSHOT.KEY", "api_base": "MOONSHOT.API_BASE"}, + "ollama": {"api_key": "OLLAMA.API_KEY", "api_base": "OLLAMA.API_BASE"}, + "openrouter": {"api_key": "OPENROUTER.KEY", "api_base": "OPENROUTER.API_BASE"}, + "replicate": {"api_key": "REPLICATE.KEY"}, + "sambanova": {"api_key": "SAMBANOVA.KEY"}, + "text-completion-codestral": {"api_key": "CODESTRAL.KEY"}, + "xai": {"api_key": "XAI.KEY"}, + "xiaomi_mimo": {"api_key": "XIAOMI_MIMO.KEY"}, + "zai": {"api_key": "ZAI.KEY"}, +} + + +AZURE_AD_TOKEN_ENV_VARS = ("AZURE_AD_TOKEN", "AZURE_OPENAI_AD_TOKEN") +AZURE_OIDC_AUTH_ENV_VARS = ("AZURE_CLIENT_SECRET", "AZURE_USERNAME", "AZURE_PASSWORD") def _as_bool(value, default: bool) -> bool: @@ -47,11 +180,44 @@ def _as_bool(value, default: bool) -> bool: return default +def _as_list(value) -> list: + """Parse a config value that may arrive as an list[str] (toml) or a string (env override).""" + if isinstance(value, (list, tuple)): + return [str(v).strip() for v in value if str(v).strip()] + if isinstance(value, str): + return [v.strip() for v in value.split(",") if v.strip()] + return [] + + +def _coerce_string_list_config(value): + """Return a list-like config value while accepting env-style strings.""" + if not value: + return [] + if isinstance(value, list): + return value + if isinstance(value, tuple): + return list(value) + if isinstance(value, str): + stripped_value = value.strip() + if not stripped_value: + return [] + if stripped_value.startswith("[") and stripped_value.endswith("]"): + try: + parsed_value = json.loads(stripped_value) + except json.JSONDecodeError: + return None + if isinstance(parsed_value, list): + return parsed_value + return None + return [model.strip() for model in stripped_value.split(",") if model.strip()] + return None + + def _configured_client_retries(): """config.num_retries as a non-negative int, or None (unset/invalid = client defaults). Invalid values are logged and ignored rather than raised: this is read on the request - path, and a config typo should not fail the run — nor be wrapped and retried as an API + path, and a config typo should not fail the run nor be wrapped and retried as an API error by the caller's exception handling. """ value = get_settings().config.get("num_retries", None) @@ -82,256 +248,212 @@ def _should_retry_same_model(exc: BaseException) -> bool: class LiteLLMAIHandler(BaseAiHandler): - """ - This class handles interactions with the OpenAI API for chat completions. - It initializes the API key and other settings from a configuration file, - and provides a method for performing chat completions using the OpenAI ChatCompletion API. + """Handle chat completions across supported providers through LiteLLM. + + Request isolation covers PR-Agent settings and captured provider environment values, + not deployment-owned LiteLLM secret managers. Embedding applications must keep + process environment and LiteLLM globals stable while requests run; this handler + is not a sandbox for external code mutating shared authentication or routing state. """ def __init__(self): - """ - Initializes the OpenAI API key and other settings from a configuration file. - Raises a ValueError if the OpenAI key is missing. - """ - self.azure = False - self.api_base = None + """Initialize provider credentials and request settings from configuration.""" + _require_litellm_interface( + JSONProviderRegistry, "JSONProviderRegistry", ("list_providers", "get", "exists", "supports_responses_api"), + ) + settings = get_settings() + self._azure_ad = bool(settings.get("AZURE_AD.CLIENT_ID", None)) + self._azure_oidc_environment = {name: os.environ.get(name) for name in AZURE_OIDC_ENV_VARS} + self._azure_oidc_auth_environment = {name: os.environ.get(name) for name in AZURE_OIDC_AUTH_ENV_VARS} + self._azure_companion_auth = bool( + self._azure_oidc_environment["AZURE_CLIENT_ID"] and ( + (self._azure_oidc_environment["AZURE_TENANT_ID"] + and self._azure_oidc_auth_environment["AZURE_CLIENT_SECRET"]) + or (self._azure_oidc_auth_environment["AZURE_USERNAME"] + and self._azure_oidc_auth_environment["AZURE_PASSWORD"]) + ) + ) + self._raw_guard_auth_snapshot = { + "generic_key": bool(getattr(litellm, "api_key", None)), + "xai_key": bool(getattr(litellm, "xai_key", None)), + "azure_key": bool( + getattr(litellm, "azure_key", None) or os.environ.get("AZURE_OPENAI_API_KEY") + or os.environ.get("AZURE_API_KEY") or self._azure_ad + or (settings.get("OPENAI.API_TYPE", None) == "azure" and settings.get("OPENAI.KEY", None)) + ), + "azure_ad_token": bool(os.environ.get("AZURE_AD_TOKEN")), + "azure_refresh": getattr(litellm, "enable_azure_ad_token_refresh", False) is True, + "azure_environment": {**self._azure_oidc_environment, **self._azure_oidc_auth_environment}, + } + try: + self._azure_ad_credential = _get_azure_ad_credential(settings) if self._azure_ad else None + except Exception as e: + get_logger().error(f"Failed to create Azure AD credential: {type(e).__name__}") + raise + self.azure = settings.get("OPENAI.API_TYPE", None) == "azure" or self._azure_ad + self._openai_api_base_is_azure = settings.get("OPENAI.API_TYPE", None) == "azure" or ( + self._azure_ad and not settings.get("AZURE_AD.API_BASE", None) + ) self.repetition_penalty = None + self._aws_use_imds = False self._aws_imds_mode = False self._aws_static_creds = None + self._aws_environment_creds = None + self._aws_active_creds = {} + self._aws_environment_credentials_incomplete = False self._aws_imds_fell_back = False self._aws_boto3_creds = None # original boto3 credentials object for IMDS refresh + self._aws_region_name = None + self._aws_credential_chain_environment = {} + self._aws_credential_chain_files = {} self._aws_bedrock_lock = asyncio.Lock() - - if get_settings().get("LITELLM.DISABLE_AIOHTTP", False): - litellm.disable_aiohttp_transport = True - if get_settings().get("OPENAI.KEY", None): - openai.api_key = get_settings().openai.key - litellm.openai_key = get_settings().openai.key - elif 'OPENAI_API_KEY' not in os.environ: - # These are process-wide globals and __init__ runs per request, so drop any - # key a previous request's provider branch left behind — chat_completion() - # forwards a truthy litellm.api_key, which would authenticate this request - # with (and leak) the earlier one's credential. The provider branches below - # repopulate it for the current request. - litellm.api_key = None - # Keyless OpenAI-compatible endpoints (vLLM, LM Studio, ...) still need *some* - # key or the OpenAI SDK raises "The api_key client option must be set". - # The placeholder must live in litellm.openai_key, which LiteLLM only consults - # on the OpenAI-compatible path: the global litellm.api_key is checked ahead of - # provider env vars (OPENROUTER_API_KEY, AZURE_API_KEY, ...) in LiteLLM's - # resolution chain, so a placeholder there silently shadows them. - litellm.openai_key = DUMMY_LITELLM_API_KEY - # Custom Bedrock endpoint (e.g. a VPC endpoint interface endpoint); litellm reads this - # directly from the environment regardless of the credentials model below - if not os.environ.get("AWS_BEDROCK_RUNTIME_ENDPOINT") and get_settings().get("aws.AWS_BEDROCK_RUNTIME_ENDPOINT"): - os.environ["AWS_BEDROCK_RUNTIME_ENDPOINT"] = get_settings().aws.AWS_BEDROCK_RUNTIME_ENDPOINT - if os.environ.get("AWS_USE_IMDS", "").strip().lower() in ("1", "true", "yes"): - import boto3 - import botocore.exceptions - session = boto3.Session() + self._aws_refresh_lock = threading.Lock() + self._vertex_credentials, self._vertex_credentials_error = self._snapshot_vertex_credentials() + self._vertex_aws_environment = { + variable: os.environ.get(variable) + for variable in ( + "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_REGION", "AWS_DEFAULT_REGION", + ) + } + self._vertex_default_adc = None + self._vertex_gac_adc = None + if ( + self._vertex_credentials is not None + and not os.environ.get("VERTEXAI_CREDENTIALS") + and os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") + ): + # Non-SDK GAC uses Google's ADC factories, not LiteLLM's explicit + # Vertex JSON factories. Reuse the captured loader without SDK + # project discovery or later file/environment fallback. + snapshot = { + "source": "gac", "info": None, "error": None, + "sdk_project": None, "sdk_project_error": None, + "quota_project": os.environ.get("GOOGLE_CLOUD_QUOTA_PROJECT"), + "gae_runtime": None, "aws_environment": dict(self._vertex_aws_environment), + } try: - creds = session.get_credentials() - if creds: - self._aws_boto3_creds = creds # store for refresh; avoids env-var re-read - self._write_frozen_aws_creds_to_env(creds.get_frozen_credentials()) - self._aws_imds_mode = True - get_logger().info("Using ambient AWS credentials from IMDS/task-role/IRSA") - else: - get_logger().warning( - "AWS_USE_IMDS is set but boto3 found no credentials; " - "falling through to static keys" - ) - except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError): - # ClientError is intentionally not a BotoCoreError subclass in botocore's - # design; it is raised by STS-backed providers (AssumeRole, IRSA web-identity). - get_logger().exception( - "AWS_USE_IMDS: failed to resolve credentials via boto3; " - "falling through to static keys" - ) - if not os.environ.get("AWS_REGION_NAME"): - if get_settings().get("aws.AWS_REGION_NAME"): - os.environ["AWS_REGION_NAME"] = get_settings().aws.AWS_REGION_NAME + snapshot["info"] = json.loads(self._vertex_credentials) + if not isinstance(snapshot["info"], dict): + raise ValueError("Invalid captured Vertex ADC") + except (TypeError, ValueError) as error: + snapshot["error"] = type(error).__name__ + identity = hashlib.sha256(json.dumps(snapshot, sort_keys=True).encode("utf-8")).hexdigest() + snapshot["cache_key"] = json.dumps({"_pr_agent_default_adc": identity}) + self._vertex_gac_adc = snapshot + if self._vertex_credentials is None and self._vertex_credentials_error is None: + from google.auth import _cloud_sdk + + snapshot = { + "info": None, "error": None, "sdk_project": None, "sdk_project_error": None, + "explicit_path": None, + "quota_project": os.environ.get("GOOGLE_CLOUD_QUOTA_PROJECT"), + "gae_runtime": os.environ.get("APPENGINE_RUNTIME"), + "aws_environment": dict(self._vertex_aws_environment), + } + try: + path = _cloud_sdk.get_application_default_credentials_path() + if os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") == path: + snapshot["explicit_path"] = path + try: + with open(path, encoding="utf-8") as adc_file: + snapshot["info"] = json.load(adc_file) + except FileNotFoundError: + pass else: + if not isinstance(snapshot["info"], dict): + raise ValueError("Invalid default Vertex credentials") try: - region = session.region_name - if region: - os.environ["AWS_REGION_NAME"] = region - get_logger().info(f"AWS region resolved from environment: {region}") - else: - get_logger().warning( - "AWS_USE_IMDS: could not determine AWS region; " - "set AWS_REGION_NAME explicitly" - ) - except Exception as e: - get_logger().warning(f"AWS_USE_IMDS: failed to resolve region via boto3: {e}") - if get_settings().get("aws.AWS_ACCESS_KEY_ID"): - if get_settings().aws.AWS_SECRET_ACCESS_KEY and get_settings().aws.AWS_REGION_NAME: - static_creds = { - "AWS_ACCESS_KEY_ID": get_settings().aws.AWS_ACCESS_KEY_ID, - "AWS_SECRET_ACCESS_KEY": get_settings().aws.AWS_SECRET_ACCESS_KEY, - "AWS_REGION_NAME": get_settings().aws.AWS_REGION_NAME, - } - static_token = get_settings().get("aws.AWS_SESSION_TOKEN", None) - if static_token: - static_creds["AWS_SESSION_TOKEN"] = static_token - if self._aws_imds_mode: - # IMDS succeeded; stash static keys for runtime fallback only - self._aws_static_creds = static_creds - else: - # IMDS failed; activate static credentials immediately and stash - # them so the runtime fallback path is also available if needed. - self._aws_static_creds = static_creds - os.environ["AWS_ACCESS_KEY_ID"] = static_creds["AWS_ACCESS_KEY_ID"] - os.environ["AWS_SECRET_ACCESS_KEY"] = static_creds["AWS_SECRET_ACCESS_KEY"] - os.environ["AWS_REGION_NAME"] = static_creds["AWS_REGION_NAME"] - if static_token: - os.environ["AWS_SESSION_TOKEN"] = static_token - elif "AWS_SESSION_TOKEN" in os.environ: - del os.environ["AWS_SESSION_TOKEN"] - get_logger().info( - "AWS_USE_IMDS: IMDS resolution failed; using static credentials" - ) - elif get_settings().get("aws.AWS_ACCESS_KEY_ID"): - assert get_settings().aws.AWS_SECRET_ACCESS_KEY and get_settings().aws.AWS_REGION_NAME, "AWS credentials are incomplete" - os.environ["AWS_ACCESS_KEY_ID"] = get_settings().aws.AWS_ACCESS_KEY_ID - os.environ["AWS_SECRET_ACCESS_KEY"] = get_settings().aws.AWS_SECRET_ACCESS_KEY - os.environ["AWS_REGION_NAME"] = get_settings().aws.AWS_REGION_NAME - static_token = get_settings().get("aws.AWS_SESSION_TOKEN", None) - if static_token: - os.environ["AWS_SESSION_TOKEN"] = static_token - elif "AWS_SESSION_TOKEN" in os.environ: - del os.environ["AWS_SESSION_TOKEN"] - if get_settings().get("LITELLM.DROP_PARAMS", None): - litellm.drop_params = get_settings().litellm.drop_params - if get_settings().get("LITELLM.SUCCESS_CALLBACK", None): - litellm.success_callback = get_settings().litellm.success_callback - if get_settings().get("LITELLM.FAILURE_CALLBACK", None): - litellm.failure_callback = get_settings().litellm.failure_callback - if get_settings().get("LITELLM.SERVICE_CALLBACK", None): - litellm.service_callback = get_settings().litellm.service_callback - # litellm's callbacks attach full prompt and response content — here, the whole - # PR diff — to whatever they emit, unless message logging is turned off. - if get_settings().get("LITELLM.TURN_OFF_MESSAGE_LOGGING", False): + snapshot["sdk_project"] = _snapshot_cloud_sdk_project(os.path.dirname(path)) + except (OSError, ValueError, configparser.Error) as error: + snapshot["sdk_project_error"] = type(error).__name__ + except (OSError, ValueError, configparser.Error) as error: + snapshot["error"] = type(error).__name__ + identity = hashlib.sha256(json.dumps(snapshot, sort_keys=True).encode("utf-8")).hexdigest() + snapshot["cache_key"] = json.dumps({"_pr_agent_default_adc": identity}) + self._vertex_default_adc = snapshot + if self._vertex_credentials: + try: + vertex_info = json.loads(self._vertex_credentials) + except (ValueError, TypeError): + vertex_info = None + if isinstance(vertex_info, dict) and vertex_info.get("type") == "external_account": + source = vertex_info.get("credential_source", {}) + environment_id = source.get("environment_id", "") if isinstance(source, dict) else "" + if isinstance(environment_id, str) and "aws" in environment_id: + # Separate different AWS identities without growing LiteLLM's + # cache for every new handler with the same captured source. + vertex_info["_pr_agent_aws_identity"] = hashlib.sha256( + json.dumps(self._vertex_aws_environment, sort_keys=True).encode("utf-8"), + ).hexdigest() + self._vertex_credentials = json.dumps(vertex_info) + self._provider_request_params = self._snapshot_provider_request_params(settings) + self._snowflake_account_id = os.environ.get("SNOWFLAKE_ACCOUNT_ID", "") + self._provider_environment_api_keys = self._snapshot_provider_environment_api_keys() + self._volcengine_ark_api_key = os.environ.get("ARK_API_KEY") + self._request_headers = self._snapshot_request_headers(settings) + sdk_headers = {} + for line in os.environ.get("OPENAI_CUSTOM_HEADERS", "").split("\n"): + if ":" not in line: + continue + name, value = line.split(":", 1) + sdk_headers[name.strip()] = value.strip() + _check_sdk_marker_collision(sdk_headers) + _check_sdk_marker_collision(self._request_headers) + self._sdk_header_defaults = { + "organization": os.environ.get("OPENAI_ORG_ID"), + "project": os.environ.get("OPENAI_PROJECT_ID"), + "custom_headers": sdk_headers, + } + openrouter_settings = settings.get("openrouter", {}) or {} + # Credentials and endpoints are isolated in _provider_request_params; keep this snapshot control-only. + self._openrouter_controls = { + key: copy.deepcopy(openrouter_settings.get(key)) + for key in ( + "provider_only", + "provider_order", + "allow_fallbacks", + "reasoning_effort", + "reasoning_max_tokens", + "max_tokens", + ) + } + self._default_reasoning_effort = getattr(settings.config, "reasoning_effort", None) + self._claude_thinking_controls = { + key: copy.deepcopy(settings.config.get(key, default)) + for key, default in ( + ("enable_claude_adaptive_thinking", False), + ("enable_claude_extended_thinking", False), + ("extended_thinking_budget_tokens", 2048), + ("extended_thinking_max_output_tokens", 4096), + ) + } + self._bedrock_model_id = settings.get("litellm.model_id", None) + self._custom_llm_provider = str( + getattr(settings.litellm, "custom_llm_provider", "") or "" + ).strip().lower() + self._anthropic_auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN") + self._request_provider_cache = {} + + if settings.get("LITELLM.DISABLE_AIOHTTP", False): + litellm.disable_aiohttp_transport = True + self._initialize_aws_request_credentials(settings) + if settings.get("LITELLM.DROP_PARAMS", None): + litellm.drop_params = settings.litellm.drop_params + if settings.get("LITELLM.SUCCESS_CALLBACK", None): + litellm.success_callback = settings.litellm.success_callback + if settings.get("LITELLM.FAILURE_CALLBACK", None): + litellm.failure_callback = settings.litellm.failure_callback + if settings.get("LITELLM.SERVICE_CALLBACK", None): + litellm.service_callback = settings.litellm.service_callback + # litellm callbacks attach full prompt and response content unless message logging is disabled. + if settings.get("LITELLM.TURN_OFF_MESSAGE_LOGGING", False): litellm.turn_off_message_logging = True - # With pr-agent's own telemetry enabled, its command span is the active parent and - # litellm's "otel" callback would skip its own request span, writing gen_ai attributes - # onto the command span instead. Keep the two layers separately aggregatable; - # setdefault leaves an explicit operator override in effect. - if self._litellm_otel_callback_enabled() and get_settings().get("OTEL.IS_ENABLED", False): + # Keep LiteLLM request spans separate from pr-agent command spans when both OTEL layers are enabled. + if self._litellm_otel_callback_enabled() and settings.get("OTEL.IS_ENABLED", False): os.environ.setdefault("USE_OTEL_LITELLM_REQUEST_SPAN", "true") - if get_settings().get("OPENAI.ORG", None): - litellm.organization = get_settings().openai.org - if get_settings().get("OPENAI.API_TYPE", None): - if get_settings().openai.api_type == "azure": - self.azure = True - litellm.azure_key = get_settings().openai.key - if get_settings().get("OPENAI.API_VERSION", None): - litellm.api_version = get_settings().openai.api_version - if get_settings().get("OPENAI.API_BASE", None): - litellm.api_base = get_settings().openai.api_base - self.api_base = get_settings().openai.api_base - if get_settings().get("ANTHROPIC.KEY", None): - litellm.anthropic_key = get_settings().anthropic.key - if get_settings().get("COHERE.KEY", None): - litellm.cohere_key = get_settings().cohere.key - if get_settings().get("GROQ.KEY", None): - litellm.api_key = get_settings().groq.key - if get_settings().get("SAMBANOVA.KEY", None): - litellm.api_key = get_settings().sambanova.key - if get_settings().get("REPLICATE.KEY", None): - litellm.replicate_key = get_settings().replicate.key - if get_settings().get("XAI.KEY", None): - litellm.api_key = get_settings().xai.key - if get_settings().get("HUGGINGFACE.KEY", None): - litellm.huggingface_key = get_settings().huggingface.key - if get_settings().get("HUGGINGFACE.API_BASE", None) and 'huggingface' in get_settings().config.model: - litellm.api_base = get_settings().huggingface.api_base - self.api_base = get_settings().huggingface.api_base - if get_settings().get("OLLAMA.API_BASE", None): - litellm.api_base = get_settings().ollama.api_base - self.api_base = get_settings().ollama.api_base - if get_settings().get("OLLAMA.API_KEY", None): - litellm.api_key = get_settings().ollama.api_key repetition_penalty = get_repetition_penalty() if repetition_penalty is not None: self.repetition_penalty = repetition_penalty - if get_settings().get("VERTEXAI.VERTEX_PROJECT", None): - litellm.vertex_project = get_settings().vertexai.vertex_project - litellm.vertex_location = get_settings().get( - "VERTEXAI.VERTEX_LOCATION", None - ) - # Google AI Studio - # SEE https://docs.litellm.ai/docs/providers/gemini - if get_settings().get("GOOGLE_AI_STUDIO.GEMINI_API_KEY", None): - os.environ["GEMINI_API_KEY"] = get_settings().google_ai_studio.gemini_api_key - - # Support deepseek models - if get_settings().get("DEEPSEEK.KEY", None): - os.environ['DEEPSEEK_API_KEY'] = get_settings().get("DEEPSEEK.KEY") - - # Support GLM (Z.AI / Zhipu) models - if get_settings().get("ZAI.KEY", None): - os.environ['ZAI_API_KEY'] = get_settings().get("ZAI.KEY") - - # Support Moonshot (Kimi) models - if get_settings().get("MOONSHOT.KEY", None): - os.environ['MOONSHOT_API_KEY'] = get_settings().get("MOONSHOT.KEY") - # Optional Moonshot endpoint override (e.g. China: https://api.moonshot.cn/v1) - if get_settings().get("MOONSHOT.API_BASE", None): - os.environ['MOONSHOT_API_BASE'] = get_settings().get("MOONSHOT.API_BASE") - - # Support Qwen (Alibaba DashScope) models - if get_settings().get("DASHSCOPE.KEY", None): - os.environ['DASHSCOPE_API_KEY'] = get_settings().get("DASHSCOPE.KEY") - - # Support Xiaomi MiMo models - if get_settings().get("XIAOMI_MIMO.KEY", None): - os.environ['XIAOMI_MIMO_API_KEY'] = get_settings().get("XIAOMI_MIMO.KEY") - - # Support deepinfra models - if get_settings().get("DEEPINFRA.KEY", None): - os.environ['DEEPINFRA_API_KEY'] = get_settings().get("DEEPINFRA.KEY") - - # Support mistral models - if get_settings().get("MISTRAL.KEY", None): - os.environ["MISTRAL_API_KEY"] = get_settings().get("MISTRAL.KEY") - - # Support codestral models - if get_settings().get("CODESTRAL.KEY", None): - os.environ["CODESTRAL_API_KEY"] = get_settings().get("CODESTRAL.KEY") - - # Support Databricks-hosted models (e.g. Azure Databricks serving endpoints). - # Uses PAT/key authentication via LiteLLM's env vars. - # SEE https://docs.litellm.ai/docs/providers/databricks - if get_settings().get("DATABRICKS.API_KEY", None): - os.environ["DATABRICKS_API_KEY"] = get_settings().get("DATABRICKS.API_KEY") - if get_settings().get("DATABRICKS.API_BASE", None): - os.environ["DATABRICKS_API_BASE"] = get_settings().get("DATABRICKS.API_BASE") - - # Check for Azure AD configuration - if get_settings().get("AZURE_AD.CLIENT_ID", None): - self.azure = True - # Generate access token using Azure AD credentials from settings - access_token = _get_azure_ad_token() - litellm.api_key = access_token - openai.api_key = access_token - - # Set API base from settings - self.api_base = get_settings().azure_ad.api_base - litellm.api_base = self.api_base - openai.api_base = self.api_base - - # Support for Openrouter models - if get_settings().get("OPENROUTER.KEY", None): - openrouter_api_key = get_settings().get("OPENROUTER.KEY", None) - os.environ["OPENROUTER_API_KEY"] = openrouter_api_key - litellm.api_key = openrouter_api_key - openai.api_key = openrouter_api_key - - openrouter_api_base = get_settings().get("OPENROUTER.API_BASE", "https://openrouter.ai/api/v1") - os.environ["OPENROUTER_API_BASE"] = openrouter_api_base - self.api_base = openrouter_api_base - litellm.api_base = openrouter_api_base # Models that only use user message self.user_message_only_models = USER_MESSAGE_ONLY_MODELS @@ -339,29 +461,59 @@ def __init__(self): # Model that doesn't support temperature argument self.no_support_temperature_models = NO_SUPPORT_TEMPERATURE_MODELS - # Models that support reasoning effort - self.support_reasoning_models = SUPPORT_REASONING_EFFORT_MODELS - - # Models that support extended thinking (config override replaces the built-in list when non-empty) - override = get_settings().config.get("claude_extended_thinking_models_override", []) or [] - if override and not isinstance(override, list): + # Config-listed models opt endpoints litellm does not know into receiving + # reasoning_effort. Reasoning support otherwise comes from litellm's own + # bundled model metadata (see _litellm_supports_reasoning). + additional_reasoning_models = _coerce_string_list_config( + get_settings().config.get("additional_reasoning_effort_models", []) + ) + if additional_reasoning_models is None: get_logger().warning( - "Invalid claude_extended_thinking_models_override in config; expected a list of model names. " - "Falling back to the built-in Claude extended-thinking model list." + "Invalid additional_reasoning_effort_models in config; expected a list of model names. " + "Ignoring it." ) - override = [] - elif override and not all(isinstance(model, str) and model.strip() for model in override): + additional_reasoning_models = [] + elif additional_reasoning_models and not all( + isinstance(model, str) and model.strip() for model in additional_reasoning_models + ): get_logger().warning( - "Invalid claude_extended_thinking_models_override in config; " + "Invalid additional_reasoning_effort_models in config; " "expected a list of model name strings. " - "Falling back to the built-in Claude extended-thinking model list." + "Ignoring it." ) - override = [] - # Store stripped names so exact-match checks against the model succeed even when the config - # entries contain surrounding whitespace (validation above already used model.strip()). - self.claude_extended_thinking_models = ( - [model.strip() for model in override] if override else CLAUDE_EXTENDED_THINKING_MODELS + additional_reasoning_models = [] + # Store stripped names so exact-match checks against the model succeed even when the + # config entries contain surrounding whitespace (validation above already used strip()). + self.additional_reasoning_effort_models = [ + model.strip() for model in additional_reasoning_models + ] + + # Models that support extended thinking (config override replaces the built-in list when non-empty) + override = self._validated_model_name_list("claude_extended_thinking_models_override") + self.claude_extended_thinking_models = override or CLAUDE_EXTENDED_THINKING_MODELS + + # Treat configured model ids as additional adaptive-only models. Add opaque Bedrock application + # inference profile ARNs while preserving built-in detection for named models. + self.claude_adaptive_thinking_models_override = self._validated_model_name_list( + "claude_adaptive_thinking_models_override" ) + bedrock_overrides = [ + model + for model in self.claude_adaptive_thinking_models_override + if model.startswith("bedrock/") or re.match(r"^arn:[^:]+:bedrock:", model) + ] + if ( + bedrock_overrides + and self._claude_thinking_controls["enable_claude_adaptive_thinking"] + ): + litellm.register_model({ + model: { + "litellm_provider": "bedrock", + "mode": "chat", + "supports_adaptive_thinking": True, + } + for model in bedrock_overrides + }) # Models that require streaming self.streaming_required_models = STREAMING_REQUIRED_MODELS @@ -393,22 +545,484 @@ def _litellm_otel_callback_enabled() -> bool: for name in ("callbacks", "success_callback", "failure_callback", "service_callback") ) + def _snapshot_provider_request_params(self, settings) -> dict: + """Capture provider credentials and endpoints for this handler instance.""" + provider_params = {} + for provider, setting_paths in PROVIDER_SETTING_PATHS.items(): + params = { + parameter: settings.get(setting_path, None) + for parameter, setting_path in setting_paths.items() + if settings.get(setting_path, None) + } + if params: + provider_params[provider] = params + + for provider, environment_variables in PROVIDER_API_BASE_ENV_VARS.items(): + if provider_params.get(provider, {}).get("api_base"): + continue + for environment_variable in environment_variables: + api_base = os.environ.get(environment_variable) + if api_base: + provider_params.setdefault(provider, {})["api_base"] = api_base + break + + if "api_base" not in provider_params.get("cloudflare", {}): + cloudflare_account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID") + if cloudflare_account_id: + provider_params.setdefault("cloudflare", {})["api_base"] = ( + f"https://api.cloudflare.com/client/v4/accounts/{cloudflare_account_id}/ai/v1" + ) + + for provider in ("watsonx", "watsonx_text"): + for parameter, environment_variables in PROVIDER_ROUTING_ENV_VARS[provider].items(): + if provider_params.get(provider, {}).get(parameter): + continue + for environment_variable in environment_variables: + value = os.environ.get(environment_variable) + if value: + provider_params.setdefault(provider, {})[parameter] = value + break + + aws_region = self._resolve_aws_region(settings) + if aws_region: + provider_params.setdefault("bedrock", {})["aws_region_name"] = aws_region + mantle_aws_region = aws_region + use_imds = os.environ.get("AWS_USE_IMDS", "").strip().lower() in ("1", "true", "yes") + has_static_credentials = all(settings.get(f"aws.{name}", None) for name in ( + "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION_NAME", + )) + if not use_imds and not has_static_credentials: + # Native Mantle ignores AWS_DEFAULT_REGION. Pin its default too, so + # the shared AWS credential snapshot cannot change the request region. + mantle_aws_region = ( + os.environ.get("AWS_REGION_NAME") + or settings.get("aws.AWS_REGION_NAME", None) + or os.environ.get("AWS_REGION") + or "us-east-1" + ) + bedrock_mantle_region = os.environ.get("BEDROCK_MANTLE_REGION") or mantle_aws_region + bedrock_mantle_api_base = provider_params.get("bedrock_mantle", {}).get("api_base") + if bedrock_mantle_api_base and MANTLE_HOST_RE is not None: + match = MANTLE_HOST_RE.match(bedrock_mantle_api_base.rstrip("/")) + if match: + bedrock_mantle_region = match.group(1) + if bedrock_mantle_region: + provider_params.setdefault("bedrock_mantle", {})["aws_region_name"] = bedrock_mantle_region + + for provider in JSONProviderRegistry.list_providers(): + provider_config = JSONProviderRegistry.get(provider) + if provider_config is None or provider_params.get(provider, {}).get("api_base"): + continue + api_base_env = getattr(provider_config, "api_base_env", None) + if not api_base_env: + continue + api_base = os.environ.get(api_base_env) + if api_base: + provider_params.setdefault(provider, {})["api_base"] = api_base + + openai_params = { + parameter: value + for parameter, value in { + "api_key": settings.get("OPENAI.KEY", None), + "api_base": ( + settings.get("OPENAI.API_BASE", None) + or os.environ.get("OPENAI_BASE_URL") + or os.environ.get("OPENAI_API_BASE") + ), + "api_version": settings.get("OPENAI.API_VERSION", None), + "organization": settings.get("OPENAI.ORG", None) or os.environ.get("OPENAI_ORGANIZATION"), + }.items() + if value + } + if openai_params: + provider_params["openai"] = openai_params + + bedrock_runtime_endpoint = ( + settings.get("aws.AWS_BEDROCK_RUNTIME_ENDPOINT", None) + or os.environ.get("AWS_BEDROCK_RUNTIME_ENDPOINT") + ) + if bedrock_runtime_endpoint: + for provider in ("bedrock", "bedrock_mantle"): + provider_params.setdefault(provider, {})[ + "aws_bedrock_runtime_endpoint" + ] = bedrock_runtime_endpoint + + configured_api_base = settings.get("OPENAI.API_BASE", None) + configured_api_version = settings.get("OPENAI.API_VERSION", None) + azure_api_base = os.environ.get("AZURE_API_BASE") + azure_api_version = os.environ.get("AZURE_API_VERSION") + azure_ad_api_base = settings.get("AZURE_AD.API_BASE", None) if self._azure_ad else None + if azure_ad_api_base: + request_api_base = azure_ad_api_base + request_api_version = configured_api_version or azure_api_version + elif self._openai_api_base_is_azure: + if configured_api_base: + request_api_base = configured_api_base + request_api_version = configured_api_version or azure_api_version + else: + request_api_base = azure_api_base + request_api_version = azure_api_version or configured_api_version + elif azure_api_base: + request_api_base = azure_api_base + request_api_version = azure_api_version or configured_api_version + else: + request_api_base = configured_api_base + request_api_version = configured_api_version or azure_api_version + + # The SDK endpoint alias is a fallback, not an override of configured routing. + request_api_base = request_api_base or os.environ.get("AZURE_OPENAI_ENDPOINT") + + # Cloudflare's key branch lets the SDK read this alias independently. + self._azure_sdk_ad_token = os.environ.get("AZURE_OPENAI_AD_TOKEN") + azure_params = { + parameter: value + for parameter, value in { + "api_key": ( + settings.get("OPENAI.KEY", None) + if settings.get("OPENAI.API_TYPE", None) == "azure" + else None + ), + "api_base": request_api_base, + "api_version": request_api_version, + "azure_ad_token": ( + os.environ.get("AZURE_AD_TOKEN") or self._azure_sdk_ad_token + ), + }.items() + if value + } + if azure_params: + provider_params["azure"] = {key: value for key, value in azure_params.items() if value} + + vertex_params = { + parameter: value + for parameter, value in { + "vertex_project": ( + settings.get("VERTEXAI.VERTEX_PROJECT", None) + or _vertex_project_from_environment() + ), + "vertex_location": ( + settings.get("VERTEXAI.VERTEX_LOCATION", None) + or os.environ.get("VERTEXAI_LOCATION") + or os.environ.get("VERTEX_LOCATION") + ), + }.items() + if value + } + vertex_credentials = self._vertex_credentials + if self._vertex_gac_adc is not None: + vertex_params["vertex_credentials"] = self._vertex_gac_adc["cache_key"] + elif vertex_credentials: + vertex_params["vertex_credentials"] = vertex_credentials + elif self._vertex_default_adc is not None: + vertex_params["vertex_credentials"] = self._vertex_default_adc["cache_key"] + if vertex_params: + provider_params.setdefault("vertex_ai", {}).update(vertex_params) + + if settings.get("OPENROUTER.KEY", None) or any( + os.environ.get(environment_variable) + for environment_variable in PROVIDER_API_KEY_ENV_VARS["openrouter"] + ): + openrouter_api_base = ( + settings.get("OPENROUTER.API_BASE", None) + or os.environ.get("OPENROUTER_API_BASE") + or "https://openrouter.ai/api/v1" + ) + provider_params.setdefault("openrouter", {}).setdefault("api_base", openrouter_api_base) + return provider_params + + @staticmethod + def _snapshot_vertex_credentials() -> tuple[str | None, str | None]: + """Capture explicit Vertex credentials before another request can change their environment.""" + vertex_credentials = os.environ.get("VERTEXAI_CREDENTIALS") + if vertex_credentials and not os.path.isfile(vertex_credentials): + try: + parsed_credentials = json.loads(vertex_credentials) + except (json.JSONDecodeError, TypeError): + pass + else: + if isinstance(parsed_credentials, dict): + return vertex_credentials, None + return None, "ValueError" + + credentials_path = vertex_credentials or os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") + if not credentials_path: + return None, None + if not vertex_credentials: + from google.auth import _cloud_sdk + + # Match Google Auth's exact-path exception so the captured SDK + # resource project remains distinct from the billing quota project. + if credentials_path == _cloud_sdk.get_application_default_credentials_path(): + return None, None + try: + with open(credentials_path, encoding="utf-8") as credentials_file: + return credentials_file.read(), None + except OSError as e: + return None, type(e).__name__ + @staticmethod - def _write_frozen_aws_creds_to_env(frozen) -> None: - """Write a botocore FrozenCredentials snapshot into os.environ for litellm/Bedrock.""" - os.environ["AWS_ACCESS_KEY_ID"] = frozen.access_key - os.environ["AWS_SECRET_ACCESS_KEY"] = frozen.secret_key - if frozen.token: - os.environ["AWS_SESSION_TOKEN"] = frozen.token - elif "AWS_SESSION_TOKEN" in os.environ: - del os.environ["AWS_SESSION_TOKEN"] - - def _refresh_aws_imds_credentials(self) -> bool: + def _snapshot_provider_environment_api_keys() -> dict: + """Capture native provider API keys without mixing them with configured credentials.""" + provider_api_keys = {} + for provider, environment_variables in PROVIDER_API_KEY_ENV_VARS.items(): + for environment_variable in environment_variables: + api_key = os.environ.get(environment_variable) + if api_key: + provider_api_keys[provider] = api_key + break + for provider in JSONProviderRegistry.list_providers(): + provider_config = JSONProviderRegistry.get(provider) + if provider_config is None or provider in provider_api_keys: + continue + api_key_env = getattr(provider_config, "api_key_env", None) + if not api_key_env: + continue + api_key = os.environ.get(api_key_env) + if api_key: + provider_api_keys[provider] = api_key + return provider_api_keys + + def _captured_api_key(self, provider: str): + """Return the API key captured for a provider, preferring configured params over the environment.""" + return ( + getattr(self, "_provider_request_params", {}).get(provider, {}).get("api_key") + or getattr(self, "_provider_environment_api_keys", {}).get(provider) + ) + + @staticmethod + def _resolve_aws_region(settings) -> str | None: + """Resolve the AWS region from the environment and the configured aws settings.""" + return ( + os.environ.get("AWS_REGION_NAME") + or settings.get("aws.AWS_REGION_NAME", None) + or os.environ.get("AWS_REGION") + or os.environ.get("AWS_DEFAULT_REGION") + ) + + @staticmethod + def _snapshot_request_headers(settings) -> dict: + """Capture explicitly configured headers for every request from this handler.""" + raw_headers = settings.get("LITELLM.EXTRA_HEADERS", None) + if not raw_headers: + return {} + try: + request_headers = json.loads(raw_headers) + except (json.JSONDecodeError, TypeError) as e: + raise ValueError(f"LITELLM.EXTRA_HEADERS contains invalid JSON: {str(e)}") from e + if not isinstance(request_headers, dict): + raise ValueError("LITELLM.EXTRA_HEADERS must be a JSON object") + return request_headers + + def _initialize_aws_request_credentials(self, settings) -> None: + """Capture request-local credentials and synchronously resolve the opted-in AWS provider chain.""" + use_imds = os.environ.get("AWS_USE_IMDS", "").strip().lower() in ("1", "true", "yes") + self._aws_use_imds = use_imds + self._aws_credential_chain_environment = { + variable: os.environ.get(variable) + for variable in AWS_CREDENTIAL_CHAIN_ENV_VARS + } + request_region = self._resolve_aws_region(settings) + ambient_access_key = os.environ.get("AWS_ACCESS_KEY_ID") + ambient_secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY") + if bool(ambient_access_key) != bool(ambient_secret_key): + self._aws_environment_credentials_incomplete = True + elif ambient_access_key and ambient_secret_key: + self._aws_environment_creds = { + "aws_access_key_id": ambient_access_key, + "aws_secret_access_key": ambient_secret_key, + # Prevent LiteLLM from falling back to a later ambient STS token. + # Only the opted-in boto3 chain recognizes the SECURITY alias; + # native LiteLLM's explicit keypair path uses SESSION alone. + "aws_session_token": ( + (os.environ.get("AWS_SECURITY_TOKEN") or os.environ.get("AWS_SESSION_TOKEN")) + if use_imds else os.environ.get("AWS_SESSION_TOKEN") + ) or "", + } + if request_region: + self._aws_environment_creds["aws_region_name"] = request_region + + static_access_key = settings.get("aws.AWS_ACCESS_KEY_ID", None) + if static_access_key: + static_secret_key = settings.get("aws.AWS_SECRET_ACCESS_KEY", None) + static_region = settings.get("aws.AWS_REGION_NAME", None) + if not (static_secret_key and static_region): + if not use_imds: + raise ValueError("AWS credentials are incomplete") + get_logger().warning( + "AWS_USE_IMDS is set but configured static AWS credentials are incomplete; " + "no static fallback is available" + ) + if static_secret_key and static_region: + self._aws_static_creds = { + "aws_access_key_id": static_access_key, + "aws_secret_access_key": static_secret_key, + # LiteLLM falls back to AWS_SESSION_TOKEN only when this value is None. + # An empty token keeps long-lived static keys isolated from ambient STS credentials. + "aws_session_token": settings.get("aws.AWS_SESSION_TOKEN", None) or "", + "aws_region_name": static_region, + } + + if not use_imds: + self._aws_active_creds = dict(self._aws_static_creds or self._aws_environment_creds or {}) + return + + if not (ambient_access_key or ambient_secret_key): + self._aws_credential_chain_files = self._snapshot_aws_credential_chain_files() + self._aws_region_name = request_region + self._initialize_aws_imds_credentials() + + def _initialize_aws_imds_credentials(self) -> bool: + """Resolve ambient AWS credentials during handler initialization without changing process credentials.""" + import boto3 + import botocore.exceptions + + self._validate_aws_credential_chain_environment() + region = self._aws_region_name + if self._aws_environment_credentials_incomplete: + if not self._aws_static_creds: + raise ValueError("AWS environment credentials are incomplete") + self._aws_active_creds = dict(self._aws_static_creds) + self._aws_imds_fell_back = True + get_logger().warning( + "AWS_USE_IMDS: ambient credentials are incomplete; using static credentials" + ) + return False + try: + session_kwargs = {} + if self._aws_environment_creds: + session_kwargs = { + "aws_access_key_id": self._aws_environment_creds["aws_access_key_id"], + "aws_secret_access_key": self._aws_environment_creds["aws_secret_access_key"], + } + if self._aws_environment_creds.get("aws_session_token"): + session_kwargs["aws_session_token"] = self._aws_environment_creds["aws_session_token"] + if region: + session_kwargs["region_name"] = region + elif os.environ.get("AWS_ACCESS_KEY_ID") or os.environ.get("AWS_SECRET_ACCESS_KEY"): + raise ValueError("Refusing live AWS credential environment fallback") + session = boto3.Session(**session_kwargs) + if not self._aws_environment_creds: + self._bind_aws_workload_token_sources(session) + if not self._aws_environment_creds and self._aws_profile_uses_credential_process(session): + if not self._aws_static_creds: + raise ValueError("AWS credential_process is incompatible with request isolation") + self._aws_active_creds = dict(self._aws_static_creds) + self._aws_imds_fell_back = True + get_logger().warning( + "AWS_USE_IMDS: credential_process is incompatible with request isolation; " + "using static credentials" + ) + return False + if not region: + try: + region = session.region_name + except Exception as e: + get_logger().warning(f"AWS_USE_IMDS: failed to resolve region via boto3: {type(e).__name__}") + creds = session.get_credentials() + if creds: + frozen_credentials = creds.get_frozen_credentials() + self._validate_aws_credential_chain_environment() + self._aws_boto3_creds = creds + self._aws_active_creds = self._aws_request_params_from_frozen(frozen_credentials, region) + self._aws_imds_mode = True + get_logger().info("Using ambient AWS credentials from IMDS/task-role/IRSA") + else: + get_logger().warning( + "AWS_USE_IMDS is set but boto3 found no credentials; falling through to static keys" + ) + except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError, OSError) as e: + # Keep provider error text and traceback locals out of credential-resolution logs. + get_logger().error( + "AWS_USE_IMDS: failed to resolve credentials via boto3; falling through to static keys: " + f"{type(e).__name__}" + ) + + if not region: + get_logger().warning("AWS_USE_IMDS: could not determine AWS region; set AWS_REGION_NAME explicitly") + if not self._aws_imds_mode and self._aws_static_creds: + self._aws_active_creds = dict(self._aws_static_creds) + self._aws_imds_fell_back = True + get_logger().info("AWS_USE_IMDS: IMDS resolution failed; using static credentials") + return self._aws_imds_mode + + def _bind_aws_workload_token_sources(self, session) -> None: + """Bind token selectors without freezing deployment-owned token rotation.""" + from botocore.credentials import AssumeRoleWithWebIdentityProvider, ContainerProvider + + environment = { + key: value for key, value in self._aws_credential_chain_environment.items() if value is not None + } + resolver = session._session.get_component("credential_provider") + # Adapt only this session's botocore providers. Its EcsContainer role + # sourcer shares the container provider; nested web identity profiles + # already disable environment selection in botocore 1.43.x. + for provider in resolver.providers: + if isinstance(provider, ContainerProvider): + provider._environ = dict(environment) + elif isinstance(provider, AssumeRoleWithWebIdentityProvider) and not provider._disable_env_vars: + values = { + key: environment[variable] + for key, variable in provider._CONFIG_TO_ENV_VAR.items() if variable in environment + } + + def get_env_config(key, values=values): + return values.get(key) + + provider._get_env_config = get_env_config + + @staticmethod + def _aws_profile_uses_credential_process(session) -> bool: + """Return whether the selected boto3 profile chain executes a credential process.""" + profile_name = getattr(session, "profile_name", None) + botocore_session = getattr(session, "_session", None) + full_config = getattr(botocore_session, "full_config", None) + if not isinstance(profile_name, str) or not isinstance(full_config, dict): + return False + + profiles = full_config.get("profiles", {}) + visited_profiles = set() + while profile_name and profile_name not in visited_profiles: + visited_profiles.add(profile_name) + profile = profiles.get(profile_name, {}) + if not isinstance(profile, dict): + return False + if profile.get("credential_process"): + return True + profile_name = profile.get("source_profile") + return False + + @staticmethod + def _aws_request_params_from_frozen(frozen, region) -> dict: + """Convert a botocore credential snapshot to LiteLLM request parameters.""" + params = { + "aws_access_key_id": frozen.access_key, + "aws_secret_access_key": frozen.secret_key, + "aws_session_token": frozen.token or "", + } + if region: + params["aws_region_name"] = region + return params + + def _read_aws_frozen_credentials(self, credentials): + """Serialize SDK refreshes without modifying handler request state.""" + try: + with self._aws_refresh_lock: + self._validate_aws_credential_chain_environment() + frozen = credentials.get_frozen_credentials() + self._validate_aws_credential_chain_environment() + return frozen + except Exception as error: + # Keep worker errors observable after cancellation without exposing + # provider details or replacing SDK errors when logging fails. + with contextlib.suppress(Exception): + get_logger().error(f"AWS credential refresh failed: {type(error).__name__}") + raise + + async def _refresh_aws_imds_credentials(self) -> bool: """Refresh ambient AWS credentials from boto3 provider chain. Called before each Bedrock call to avoid serving stale credentials from long-lived processes (EC2 roles rotate every ~6h). - Uses the credentials object stored during __init__ rather than creating a new boto3.Session, - which would read the already-set AWS_* env vars and return stale values. + Uses the credentials object stored during initial ambient resolution rather than creating a new boto3.Session. Returns True on success, False on failure (caller should trigger static fallback).""" import botocore.exceptions @@ -416,24 +1030,610 @@ def _refresh_aws_imds_credentials(self) -> bool: if self._aws_boto3_creds is None: get_logger().warning("IMDS credential refresh: no boto3 credentials object stored") return False - self._write_frozen_aws_creds_to_env(self._aws_boto3_creds.get_frozen_credentials()) - return True - except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError): + region = self._aws_active_creds.get("aws_region_name") + frozen_credentials = await asyncio.to_thread(self._read_aws_frozen_credentials, self._aws_boto3_creds) + params = self._aws_request_params_from_frozen(frozen_credentials, region) + except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError, OSError): # ClientError (STS/AssumeRole failures) is not a BotoCoreError subclass. - get_logger().exception("IMDS credential refresh failed") return False + # Commit only the uncancelled caller's result under the async lock. + # Recheck after resumption without turning trust failures into fallback. + self._validate_aws_credential_chain_environment() + self._aws_active_creds = params + return True def _activate_static_aws_fallback(self): - """Swap process env to static credentials for Bedrock fallback after IMDS failure.""" - os.environ["AWS_ACCESS_KEY_ID"] = self._aws_static_creds["AWS_ACCESS_KEY_ID"] - os.environ["AWS_SECRET_ACCESS_KEY"] = self._aws_static_creds["AWS_SECRET_ACCESS_KEY"] - os.environ["AWS_REGION_NAME"] = self._aws_static_creds["AWS_REGION_NAME"] - if "AWS_SESSION_TOKEN" in self._aws_static_creds: - os.environ["AWS_SESSION_TOKEN"] = self._aws_static_creds["AWS_SESSION_TOKEN"] - elif "AWS_SESSION_TOKEN" in os.environ: - del os.environ["AWS_SESSION_TOKEN"] + """Select static request credentials for an AWS provider fallback after IMDS failure.""" + self._aws_active_creds = dict(self._aws_static_creds) self._aws_imds_fell_back = True - get_logger().warning("Bedrock call failed with ambient (IMDS) credentials; retrying with static credentials") + get_logger().warning("AWS provider call failed with ambient credentials; retrying with static credentials") + + def _validate_aws_credential_chain_environment(self) -> None: + """Reject credential-chain selectors changed after this handler was initialized.""" + if any( + os.environ.get(variable) != value + for variable, value in self._aws_credential_chain_environment.items() + ): + raise ValueError("Refusing changed AWS credential-chain environment") + if ( + self._aws_credential_chain_files + and self._snapshot_aws_credential_chain_files() != self._aws_credential_chain_files + ): + raise ValueError("Refusing changed AWS credential-chain file") + + def _validate_aws_request_endpoint_environment(self) -> None: + """Reject AWS request endpoints changed after this handler was initialized.""" + if any( + os.environ.get(variable) != self._aws_credential_chain_environment.get(variable) + for variable in AWS_REQUEST_ENDPOINT_ENV_VARS + ): + raise ValueError("Refusing changed AWS request endpoint environment") + + @staticmethod + def _original_ec2_credential_file_path() -> str | None: + """Return the path that botocore's OriginalEC2Provider would read.""" + if original_ec2_credentials := os.environ.get("AWS_CREDENTIAL_FILE"): + return os.path.abspath(os.path.expanduser(original_ec2_credentials)) + return None + + @classmethod + def _aws_credential_chain_file_paths(cls) -> tuple[str, ...]: + """Return credential files that boto3 may read for this handler.""" + home = os.path.expanduser("~") + paths = [] + for variable, default_path in ( + ("AWS_SHARED_CREDENTIALS_FILE", os.path.join(home, ".aws", "credentials")), + ("AWS_CONFIG_FILE", os.path.join(home, ".aws", "config")), + ): + configured_path = os.environ.get(variable) + if configured_path is None: + paths.append(default_path) + elif configured_path: + paths.append(configured_path) + boto_config = os.environ.get("BOTO_CONFIG") + if boto_config is None: + paths.extend(("/etc/boto.cfg", os.path.join(home, ".boto"))) + elif boto_config: + paths.append(boto_config) + normalized_paths = [ + os.path.abspath(os.path.expanduser(os.path.expandvars(path))) + for path in paths + ] + if original_ec2_credentials := cls._original_ec2_credential_file_path(): + normalized_paths.append(original_ec2_credentials) + return tuple(dict.fromkeys(normalized_paths)) + + @staticmethod + def _fingerprint_aws_credential_chain_file(path: str) -> tuple: + """Return a stable fingerprint without retaining credential file contents.""" + try: + if not stat.S_ISREG(os.stat(path).st_mode): + return "nonfile", + with open(path, "rb") as credential_file: + return "file", hashlib.file_digest(credential_file, "sha256").hexdigest() + except FileNotFoundError: + return "missing", + except OSError as error: + return "error", type(error).__name__, error.errno + + @classmethod + def _snapshot_aws_credential_chain_files(cls) -> dict: + """Capture fingerprints for boto3 credential-chain files.""" + fingerprints = { + path: cls._fingerprint_aws_credential_chain_file(path) + for path in cls._aws_credential_chain_file_paths() + } + original_ec2_credentials = cls._original_ec2_credential_file_path() + if original_ec2_credentials and fingerprints.get(original_ec2_credentials) == ("nonfile",): + raise ValueError("AWS_CREDENTIAL_FILE must reference a regular file") + return fingerprints + + @contextlib.asynccontextmanager + async def _snapshot_aws_request_credentials(self, enabled): + """Refresh off-loop and serialize this handler's AWS call and static fallback.""" + if not enabled: + yield dict(self._aws_active_creds), False + return + async with self._aws_bedrock_lock: + if not self._aws_imds_fell_back: + self._validate_aws_credential_chain_environment() + if self._aws_imds_mode and not await self._refresh_aws_imds_credentials() and self._aws_static_creds: + self._activate_static_aws_fallback() + can_fallback = self._aws_imds_mode and not self._aws_imds_fell_back and bool(self._aws_static_creds) + yield dict(self._aws_active_creds), can_fallback + + def _should_use_aws_imds(self, provider: str | None) -> bool: + """Return whether this request needs SigV4 credentials from the ambient AWS chain.""" + if not getattr(self, "_aws_use_imds", False) or provider not in AWS_REQUEST_PROVIDERS: + return False + if provider not in ("bedrock", "bedrock_mantle"): + return True + provider_params = getattr(self, "_provider_request_params", {}).get(provider, {}) + provider_environment_api_keys = getattr(self, "_provider_environment_api_keys", {}) + return not (provider_params.get("api_key") or provider_environment_api_keys.get(provider)) + + def _resolve_request_provider(self, model: str) -> str | None: + """Resolve the LiteLLM provider after PR-Agent has normalized the model name.""" + if not isinstance(model, str) or not model: + return None + provider_cache = getattr(self, "_request_provider_cache", None) + if provider_cache is None: + provider_cache = self._request_provider_cache = {} + if model in provider_cache: + return provider_cache[model] + transport_provider = None + if "/" in model: + transport_provider = model.split("/", 1)[0] + provider = PROVIDER_SETTING_ALIASES.get(transport_provider, transport_provider) + provider_params = getattr(self, "_provider_request_params", {}) + if ( + provider in provider_params + or provider in PROVIDER_API_KEY_ENV_VARS + or provider in AWS_REQUEST_PROVIDERS + or provider in OPENAI_COMPATIBLE_REQUEST_PROVIDERS + or _is_openai_compatible_request_provider(provider) + or provider in getattr(litellm, "provider_list", ()) + or provider in ("azure", "databricks", "openai", "vertex_ai") + ): + resolved_provider = provider + else: + resolved_provider = None + else: + try: + _, transport_provider, _, _ = litellm.get_llm_provider(model=model) + resolved_provider = PROVIDER_SETTING_ALIASES.get(transport_provider, transport_provider) + except litellm.BadRequestError: + if model.startswith("claude"): + resolved_provider = "anthropic" + elif model.startswith("command"): + resolved_provider = "cohere_chat" + else: + resolved_provider = "openai" + transport_provider = resolved_provider + provider_cache[model] = resolved_provider + transport_provider_cache = getattr(self, "_request_transport_provider_cache", None) + if transport_provider_cache is None: + transport_provider_cache = self._request_transport_provider_cache = {} + transport_provider_cache[model] = transport_provider + return resolved_provider + + def _resolve_request_transport_provider(self, model: str) -> str | None: + """Resolve the unaliased provider LiteLLM uses to select a transport.""" + self._resolve_request_provider(model) + return getattr(self, "_request_transport_provider_cache", {}).get(model) + + def _route_model(self, model: str, deployment_id: str | None) -> str: + """Apply provider routing shared by regular calls and health probes.""" + if model.startswith("azure_text/") and deployment_id: + return f"azure_text/{deployment_id}" + if self.azure: + if "/" not in model: + return "azure/" + model + provider, model_name = model.split("/", 1) + if provider == "azure_text" or provider == "openai" or PROVIDER_SETTING_ALIASES.get(provider) == "openai": + azure_provider = "azure_text" if provider in ("text-completion-openai", "azure_text") else "azure" + if azure_provider == "azure_text" and deployment_id: + model_name = deployment_id + return f"{azure_provider}/{model_name}" + return model + + def _route_model_for_request( + self, + model: str, + custom_llm_provider: str, + deployment_id: str | None, + ) -> str: + """Apply automatic routing while preserving explicit custom-provider model IDs.""" + if not custom_llm_provider: + model = self._route_model(model, deployment_id) + elif deployment_id and (custom_llm_provider == "azure_text" or model.startswith("azure_text/")): + model = f"azure_text/{deployment_id}" + return normalize_litellm_model(model, custom_llm_provider) + + @staticmethod + def _canonical_openrouter_model(model: str, provider: str | None) -> str | None: + """Return an OpenRouter-prefixed model for request-control matching.""" + if provider != "openrouter" or not isinstance(model, str): + return None + return model if model.startswith("openrouter/") else f"openrouter/{model}" + + @staticmethod + def _is_gpt6_astra_model(model: str) -> bool: + """Recognize native Astra models without changing gateway model IDs.""" + while model.startswith(("openai/", "azure/")): + model = model.removeprefix("openai/").removeprefix("azure/") + return model.removesuffix("_thinking") == "gpt-6-astra" + + @staticmethod + def _is_gpt5_model(model: str) -> bool: + """Return whether a routed model belongs to the GPT-5 family.""" + model_base = model.removeprefix("openrouter/") + while model_base.startswith(("openai/", "azure/")): + model_base = model_base.removeprefix("openai/").removeprefix("azure/") + return model_base.startswith("gpt-5") + + def _normalize_gpt5_model_for_request(self, model: str, user_model: str, custom_llm_provider: str) -> str: + """Normalize GPT-5/Astra suffixes and prefixes before request parameters are selected.""" + model_base = model + while model_base.startswith(("openai/", "azure/")): + model_base = model_base.removeprefix("openai/").removeprefix("azure/") + if not model_base.startswith("gpt-5") and model_base.removesuffix("_thinking") != "gpt-6-astra": + return model + if custom_llm_provider: + return model.replace("_thinking", "") + if self.azure or user_model.startswith("azure/"): + provider_prefix = "azure/" + else: + provider_prefix = "openai/" + return provider_prefix + model_base.replace("_thinking", "") + + def _uses_captured_azure_companion_auth(self, provider): + if provider not in ("azure", "azure_ai") or not getattr(self, "_azure_companion_auth", False): + return False + if provider == "azure_ai": + # This bridge covers initially tokenless raw companion selection, + # not another Azure key or AD-token choice in the common validator. + snapshot = self._raw_guard_auth_snapshot + return not (snapshot["azure_key"] or snapshot["azure_ad_token"]) + return True + + def _get_provider_request_params( + self, + model: str, + azure_ad_token=None, + provider=None, + transport_provider=None, + transport_model=None, + aws_request_credentials=None, + ) -> dict: + """Return only the credentials and routing parameters for this model's provider.""" + provider = provider or self._resolve_request_provider(model) + if provider in AWS_REQUEST_PROVIDERS: + self._validate_aws_request_endpoint_environment() + if transport_provider is None: + transport_provider = self._resolve_request_transport_provider(model) or provider + provider_params = getattr(self, "_provider_request_params", {}) + provider_environment_api_keys = getattr(self, "_provider_environment_api_keys", {}) + openai_api_base_is_azure = getattr(self, "_openai_api_base_is_azure", getattr(self, "azure", False)) + if provider in OPENAI_COMPATIBLE_REQUEST_PROVIDERS: + openai_params = {} if openai_api_base_is_azure else provider_params.get("openai", {}) + params = { + key: openai_params[key] + for key in ("api_key", "api_base") + if openai_params.get(key) + } + native_api_key = provider_environment_api_keys.get(provider) + if provider == "openai_like" and provider_params.get(provider, {}).get("api_base"): + params["api_base"] = provider_params[provider]["api_base"] + if params["api_base"] != openai_params.get("api_base"): + params.pop("api_key", None) + if provider == "openai_like" and native_api_key: + params["api_key"] = native_api_key + elif "api_key" not in params and native_api_key: + params["api_key"] = native_api_key + if provider == "openai_like" and "api_key" not in params and ( + openai_params.get("api_key") + or getattr(litellm, "api_key", None) + or getattr(litellm, "openai_key", None) + or _has_provider_api_key_global(provider) + or provider_environment_api_keys.get("openai") + or _has_live_provider_api_key_environment(provider) + or os.environ.get("OPENAI_API_KEY") + ): + params["api_key"] = DUMMY_LITELLM_API_KEY + if provider == "custom_openai" and "api_key" not in params: + params["api_key"] = DUMMY_LITELLM_API_KEY + if provider == "custom_openai" and "api_base" not in params: + params["api_base"] = OPENAI_DEFAULT_API_BASE + request_headers = _request_local_openai_headers(transport_provider, model=transport_model or model) + if request_headers is not None: + params["headers"] = request_headers + return self._finalize_provider_request_params(provider, params) + if provider is None: + # OPENAI.API_BASE also configures gateways such as MOSAICO whose model + # names can retain another provider prefix. Forward only the matching + # request-local key to that explicitly configured endpoint. + openai_params = ( + {} + if openai_api_base_is_azure + else getattr(self, "_provider_request_params", {}).get("openai", {}) + ) + api_base = openai_params.get("api_base") + params = {"api_base": api_base} if api_base else {} + request_api_key = openai_params.get("api_key") or provider_environment_api_keys.get("openai") + if api_base: + params["api_key"] = request_api_key or DUMMY_LITELLM_API_KEY + elif ( + getattr(litellm, "api_key", None) + or getattr(litellm, "openai_key", None) + or os.environ.get("OPENAI_API_KEY") + or getattr(openai, "api_key", None) + ): + params["api_key"] = DUMMY_LITELLM_API_KEY + return self._finalize_provider_request_params(provider, params) + if provider in MANAGED_AUTH_REQUEST_PROVIDERS: + params = dict(provider_params.get(provider, {})) + params["api_key"] = DUMMY_LITELLM_API_KEY + request_headers = _request_local_openai_headers(transport_provider, model=transport_model or model) + if request_headers is not None: + params["headers"] = request_headers + return self._finalize_provider_request_params(provider, params) + params = dict(provider_params.get(provider, {})) + if provider == "gdc": + api_base = params.get("api_base") + if not api_base: + raise ValueError("GDC API base was not resolved for this request; set GDC_API_BASE") + if "/v1/projects/" not in api_base: + from litellm.llms.gdc.chat.transformation import GDCGeminiConfig + + routing = { + name: provider_params.get("vertex_ai", {}).get(name) + for name in ("vertex_project", "vertex_location") + } + if not all(routing.values()): + raise ValueError("GDC host routing requires captured Vertex project and location") + # Native dispatch places routing kwargs below process globals. + # Resolve the full URL first, using the native highest-priority + # inputs, so authentication and quota headers share this route. + params["api_base"] = GDCGeminiConfig().get_complete_url( + api_base=api_base, api_key=params.get("api_key"), model=transport_model or model, + optional_params={}, litellm_params=routing, + ) + if provider == "vertex_ai": + if self._vertex_credentials_error: + raise ValueError( + f"Unable to snapshot explicit Vertex credentials: {self._vertex_credentials_error}" + ) + if self._vertex_credentials is None: + captured_path = (self._vertex_default_adc or {}).get("explicit_path") + live_path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") + if os.environ.get("VERTEXAI_CREDENTIALS") or (live_path and live_path != captured_path): + raise ValueError("Refusing live Vertex credential environment fallback") + if provider == "openrouter" and not params and not provider_environment_api_keys.get("openrouter"): + openai_params = {} if openai_api_base_is_azure else provider_params.get("openai", {}) + gateway_api_base = openai_params.get("api_base") + gateway_api_key = openai_params.get("api_key") or provider_environment_api_keys.get("openai") + if gateway_api_base and gateway_api_key: + params.update(api_base=gateway_api_base, api_key=gateway_api_key) + if provider == "openrouter" and "api_base" not in params and ( + params.get("api_key") + or provider_environment_api_keys.get("openrouter") + or _has_live_provider_api_key_environment("openrouter") + ): + params["api_base"] = "https://openrouter.ai/api/v1" + volcengine_responses = provider == "volcengine" and _uses_openai_responses_transport( + transport_model or model, transport_provider, + ) + if volcengine_responses: + # Native provider resolution prefers VOLCENGINE_API_KEY; only the + # Responses transport falls back to ARK_API_KEY afterward. + if "api_key" not in params: + api_key = provider_environment_api_keys.get(provider) or getattr(self, "_volcengine_ark_api_key", None) + if api_key: + params["api_key"] = api_key + if ( + _is_openai_compatible_request_provider(provider) + and not params + and not provider_environment_api_keys.get(provider) + ): + # Use OPENAI.API_BASE only when this provider has no native request + # identity. This prevents sending provider credentials to a gateway. + api_base = None if openai_api_base_is_azure else provider_params.get("openai", {}).get("api_base") + if api_base: + params["api_base"] = api_base + params["api_key"] = ( + provider_params.get("openai", {}).get("api_key") + or provider_environment_api_keys.get("openai") + or DUMMY_LITELLM_API_KEY + ) + if volcengine_responses and "api_key" not in params and os.environ.get("ARK_API_KEY"): + raise ValueError("Refusing Volcengine ARK API key added after handler initialization") + if provider in ("watsonx", "watsonx_text") and "api_key" not in params and ( + params.get("token") or params.get("zen_api_key") + ): + # LiteLLM resolves a Watsonx API key before applying explicit token + # or Zen authentication. Block that ambient fallback request-locally. + params["api_key"] = DUMMY_LITELLM_API_KEY + if "api_key" not in params: + api_key = provider_environment_api_keys.get(provider) + if api_key: + params["api_key"] = api_key + if provider == "snowflake": + # Require captured identity before the generic placeholder guard; + # native Snowflake cannot authenticate a keyless request. + if not params.get("api_key"): + raise ValueError("Snowflake JWT was not resolved for this request; set SNOWFLAKE_JWT") + if not params.get("api_base"): + account_id = getattr(self, "_snowflake_account_id", "") + if not account_id: + raise ValueError("Snowflake account was not resolved for this request; set SNOWFLAKE_ACCOUNT_ID") + params["account_id"] = account_id + if provider in ("hosted_vllm", "lm_studio") and params.get("api_base") and "api_key" not in params: + params["api_key"] = DUMMY_LITELLM_API_KEY + # Unlike Mantle, classic Bedrock never consumes litellm.api_key. + if provider == "bedrock" and "api_key" not in params and _has_live_provider_api_key_environment(provider): + raise ValueError("Refusing process-wide Bedrock bearer token fallback") + if provider in ("sagemaker_chat", "sagemaker_nova") and os.environ.get("AWS_BEARER_TOKEN_BEDROCK"): + # LiteLLM 1.101.0's SageMaker signer ignores its api_key argument and + # otherwise reads this Bedrock-only token directly from the environment. + raise ValueError("Refusing Bedrock bearer token fallback for SageMaker") + if provider == "azure" and getattr(self, "_azure_ad", False): + if azure_ad_token is None: + raise ValueError("Azure AD token was not resolved for this request") + params.pop("api_key", None) + params["azure_ad_token"] = azure_ad_token + if provider == "azure" and "azure_ad_token" not in params and any( + os.environ.get(environment_variable) for environment_variable in AZURE_AD_TOKEN_ENV_VARS + ): + raise ValueError("Refusing Azure AD token added after handler initialization") + if provider == "anthropic" and "api_key" not in params: + # LiteLLM has no completion parameter for ANTHROPIC_AUTH_TOKEN. The + # placeholder blocks ambient API keys until the request-local bridge + # restores Anthropic's keyless state and supplies the bearer token. + params["api_key"] = DUMMY_LITELLM_API_KEY + requires_api_key_guard = provider == "bedrock_mantle" or ( + provider not in AWS_REQUEST_PROVIDERS + and provider != "vertex_ai" + and (_uses_provider_api_key(provider) or provider in getattr(litellm, "provider_list", ())) + ) + companion_auth = ( + self._uses_captured_azure_companion_auth(provider) + and not _is_cloudflare_gateway(params.get("api_base")) + ) + # Token- or companion-based Azure requests must block even a key that appears only + # after native dispatch starts, not just an already visible fallback. + if "api_key" not in params and requires_api_key_guard and ( + (provider == "azure" and params.get("azure_ad_token")) + or companion_auth + or getattr(litellm, "api_key", None) + or _has_provider_api_key_global(provider) + or ( + _is_openai_compatible_request_provider(provider) + and (getattr(litellm, "openai_key", None) or provider_environment_api_keys.get("openai")) + ) + or _has_live_provider_api_key_environment(provider) + ): + params["api_key"] = DUMMY_LITELLM_API_KEY + params["_raw_api_key_guard"] = True + if provider == "azure" and str(params.get("azure_ad_token") or "").startswith("oidc/"): + params["_azure_oidc_guard"] = True + elif provider == "azure" and (params.get("azure_ad_token") or companion_auth): + params["_azure_ad_guard"] = True + if provider == "openai" and "api_key" not in params: + # Do not trust process-wide LiteLLM globals here: another request or + # embedding may have populated them with a different tenant's key. + params["api_key"] = DUMMY_LITELLM_API_KEY + if provider == "openai" and "api_base" not in params: + params["api_base"] = OPENAI_DEFAULT_API_BASE + if provider == "gemini": + # Freeze absence before LiteLLM moves dispatch to an executor, where + # a later key/base would otherwise replace the native default route. + params.setdefault("api_key", DUMMY_LITELLM_API_KEY) + if "api_base" not in params: + from litellm.llms.vertex_ai.common_utils import _get_gemini_url + + native_model = (transport_model or model).removeprefix("gemini/") + url, endpoint = _get_gemini_url(mode="chat", model=native_model, stream=False) + suffix = f"/models/{native_model}:{endpoint}" + if not url.endswith(suffix) or len(url) == len(suffix): + raise RuntimeError("LiteLLM's Gemini default URL is incompatible with request isolation") + params["api_base"] = url[:-len(suffix)] + if provider in AWS_REQUEST_PROVIDERS: + model_region = ( + _get_bedrock_model_region(transport_model or model, getattr(self, "_bedrock_model_id", None)) + if provider == "bedrock" else None + ) + if model_region: + # Native model regions precede environment/settings regions; + # capturing credentials must not promote their region above it. + params["aws_region_name"] = model_region + uses_bedrock_bearer = ( + provider in ("bedrock", "bedrock_mantle") + and params.get("api_key") not in (None, DUMMY_LITELLM_API_KEY) + ) + if not uses_bedrock_bearer: + if any(os.environ.get(variable) for variable in LITELLM_AWS_CREDENTIAL_SELECTOR_ENV_VARS): + # LiteLLM 1.101.0 resolves these selectors ahead of explicit + # request credentials, which would replace the isolated keys. + raise ValueError(f"Refusing ambient LiteLLM AWS credential selector for provider {provider}") + aws_request_credentials = dict(aws_request_credentials or {}) + if (provider == "bedrock_mantle" or model_region) and params.get("aws_region_name"): + aws_request_credentials["aws_region_name"] = params["aws_region_name"] + if not ( + aws_request_credentials.get("aws_access_key_id") + and aws_request_credentials.get("aws_secret_access_key") + ): + if getattr(self, "_aws_environment_credentials_incomplete", False): + raise ValueError("AWS environment credentials are incomplete") + raise ValueError("AWS credentials were not resolved for this request") + if not aws_request_credentials.get("aws_region_name"): + raise ValueError( + "AWS region was not resolved for this request; set AWS_REGION_NAME, " + "aws.AWS_REGION_NAME, AWS_REGION, or AWS_DEFAULT_REGION" + ) + params.update(aws_request_credentials) + request_headers = _request_local_openai_headers( + transport_provider, + params.get("organization"), + transport_model or model, + ) + if request_headers is not None: + params["headers"] = request_headers + return self._finalize_provider_request_params(provider, params) + + def _finalize_provider_request_params(self, provider: str | None, params: dict) -> dict: + """Merge request-local headers and reject LiteLLM's process-wide header fallback.""" + params = _guard_request_routing_globals(provider, params) + watsonx_token = params.pop("token", None) if provider in ("watsonx", "watsonx_text") else None + request_headers = dict(getattr(self, "_request_headers", {})) + for header, value in (params.get("headers") or {}).items(): + matching_headers = [name for name in request_headers if name.lower() == header.lower()] + if matching_headers and isinstance(value, openai.Omit): + value = request_headers[matching_headers[-1]] + for matching_header in matching_headers: + del request_headers[matching_header] + request_headers[header] = value + authorization_headers = [header for header in request_headers if header.lower() == "authorization"] + has_authorization = bool(authorization_headers) + if provider in ("watsonx", "watsonx_text") and has_authorization: + authorization = request_headers[authorization_headers[-1]] + for header in authorization_headers: + del request_headers[header] + request_headers["Authorization"] = authorization + if watsonx_token and not has_authorization: + request_headers["Authorization"] = f"Bearer {watsonx_token}" + has_authorization = True + if provider in ("watsonx", "watsonx_text") and has_authorization: + params.pop("zen_api_key", None) + params["api_key"] = DUMMY_LITELLM_API_KEY + if request_headers: + params["headers"] = request_headers + elif getattr(litellm, "headers", None): + raise ValueError(f"Refusing process-wide LiteLLM headers fallback for provider {provider or 'unknown'}") + return params + + def _requires_streaming(self, model: str) -> bool: + """Return whether this model requires streaming after OpenAI/Azure routing.""" + def normalize(candidate: str) -> str: + while candidate.startswith(("azure/", "openai/")): + candidate = candidate.removeprefix("azure/").removeprefix("openai/") + return candidate + + normalized_model = normalize(model) + return any(normalize(candidate) == normalized_model for candidate in self.streaming_required_models) + + def _force_streaming_for_request(self, custom_llm_provider, api_base) -> bool: + """Return whether an OpenAI-compatible endpoint requires streaming.""" + custom_llm_provider = str(custom_llm_provider or "").strip().lower() + api_base = api_base.strip().lower() if isinstance(api_base, str) else "" + return ( + bool(custom_llm_provider) + and custom_llm_provider == self.force_streaming_provider + and bool(self.force_streaming_api_base_substrings) + and any(substring in api_base for substring in self.force_streaming_api_base_substrings) + ) + + async def _get_provider_request_params_async( + self, + model: str, + provider=None, + transport_provider=None, + transport_model=None, + aws_request_credentials=None, + ) -> dict: + """Resolve provider parameters without blocking the event loop on Azure AD refresh.""" + provider = provider or self._resolve_request_provider(model) + azure_ad_token = None + if getattr(self, "_azure_ad", False) and provider == "azure": + azure_ad_token = await asyncio.to_thread(_get_azure_ad_token, self._azure_ad_credential) + return self._get_provider_request_params( + model, + azure_ad_token=azure_ad_token, + provider=provider, + transport_provider=transport_provider, + transport_model=transport_model, + aws_request_credentials=aws_request_credentials, + ) def prepare_logs(self, response, system, user, resp, finish_reason): response_log = response.dict().copy() @@ -516,6 +1716,78 @@ def _has_priceable_usage(usage) -> bool: ) ) + @staticmethod + def _litellm_supports_reasoning(model: str) -> bool: + """Probe litellm's bundled model metadata for reasoning support. + + The metadata lookup is exact per spelling, so a model id only resolves when + the queried form is registered. The six Grok ids are registered only under + the ``xai/`` provider prefix (and are also guaranteed by the caller via the + GROK_REASONING_EFFORT_LEVELS registry, so they do not depend on map versions) + and bare o3/o4/Gemini ids resolve directly. To mirror the old + ``endswith("/")`` membership, probe every suffix of the id (after + stripping the leading ``openrouter/`` segment, whose provider-prefixed slugs + can carry metadata for models this handler never routed there) plus the + ``xai/``-prefixed bare name. Routing suffixes such as ``:nitro`` are stripped + by the OpenRouter caller before this gate runs; for every other provider the + full tagged id is kept, so a local ``ollama/o3:latest`` only resolves when its + exact spelling is registered and does not fall through to the bare ``o3`` + entry. Claude models are excluded by the caller via _is_claude_family_model: + litellm maps their reasoning_effort to a thinking token budget. + + The bundled cost map is consulted directly rather than via + ``litellm.supports_reasoning``: that public helper resolves the model through + ``get_llm_provider`` on every call, and provider resolution must stay out of + this gate because the api key guard snapshots the resolved provider and its + key. The map is also what #3475 pins through ``LITELLM_LOCAL_MODEL_COST_MAP`` + (now in every Dockerfile stage), so the reads are deterministic for the model + ids this gate handles. + """ + probe = model + if probe.startswith("openrouter/"): + probe = probe.removeprefix("openrouter/") + segments = probe.split("/") + candidates = [] + for i in range(len(segments)): + candidates.append("/".join(segments[i:])) + if i == len(segments) - 1: + candidates.append(f"xai/{segments[-1]}") + try: + return any( + LiteLLMAIHandler._model_cost_entry_supports_reasoning(candidate) + for candidate in candidates + ) + except Exception as e: + get_logger().warning( + f"Failed to probe litellm reasoning metadata for {model}: {e}" + ) + return False + + @staticmethod + def _model_cost_entry_supports_reasoning(model: str) -> bool: + """Return whether the bundled cost map flags one exact model id as reasoning-capable. + + Mirrors ``litellm.supports_reasoning``'s field check for an entry that exists: + only an explicit True counts. Missing entries and absent capability fields do + not enable the reasoning-effort path; the caller probes every candidate spelling + so a provider-prefixed model still resolves through its bare or ``xai/`` form. + """ + entry = litellm.model_cost.get(model) + return isinstance(entry, dict) and entry.get("supports_reasoning") is True + + @staticmethod + def _is_claude_family_model(model: str) -> bool: + """Recognize Claude model ids through any provider prefix. + + Claude reasoning is configured only through the dedicated extended/adaptive + thinking settings, so the generic reasoning_effort path must not pick these + models up even though litellm marks claude-sonnet-4-5 and claude-haiku-4-5 + as reasoning-capable: litellm maps reasoning_effort to a thinking token + budget there, silently enabling thinking for a model not opted in. + """ + normalized = model.lower().replace("_", "-").replace(".", "-") + return re.search(r"claude(?:-|$)", normalized) is not None + @staticmethod def _grok_reasoning_levels_for(model: str) -> set[str] | None: """Return the reasoning-effort levels accepted by a registered Grok model.""" @@ -543,6 +1815,292 @@ def _clamp_grok_reasoning_effort(cls, model: str, reasoning_effort: str) -> str: return "xhigh" if "xhigh" in grok_levels else "high" return "low" + def _resolve_reasoning_effort(self, model: str, configured_effort) -> str: + """Validate and normalize a configured reasoning effort for this model.""" + try: + ReasoningEffort(configured_effort) + reasoning_effort = configured_effort + except (ValueError, TypeError): + reasoning_effort = ReasoningEffort.MEDIUM.value + if configured_effort is not None: + get_logger().warning( + f"Invalid reasoning_effort '{configured_effort}' in config. " + f"Using default '{reasoning_effort}'. Valid values: {[e.value for e in ReasoningEffort]}" + ) + + clamped_effort = self._clamp_grok_reasoning_effort(model, reasoning_effort) + if clamped_effort != reasoning_effort: + get_logger().info( + f"Grok model {model} does not support reasoning_effort='{reasoning_effort}'; " + f"using '{clamped_effort}' instead." + ) + return clamped_effort + + def _apply_openrouter_request_controls( + self, + model: str, + kwargs: dict, + inherited_reasoning_effort: str | None = None, + ) -> dict: + """Apply handler-local OpenRouter routing, reasoning, and output controls.""" + openrouter_settings = self._openrouter_controls + extra_body = kwargs.get("extra_body") or {} + + # Normalize operator-controlled config: Dynaconf/env overrides can + # arrive as strings (AUTO_CAST_FOR_DYNACONF is disabled), so coerce + # defensively instead of trusting the declared types. + provider_only = _as_list(openrouter_settings.get("provider_only", [])) + provider_order = _as_list(openrouter_settings.get("provider_order", [])) + if provider_only: + extra_body.setdefault("provider", {})["only"] = provider_only + elif provider_order: + provider = extra_body.setdefault("provider", {}) + provider["order"] = provider_order + provider["allow_fallbacks"] = _as_bool(openrouter_settings.get("allow_fallbacks", True), default=True) + + reasoning = {} + effective_reasoning_effort = str( + openrouter_settings.get("reasoning_effort", "") or "" + ).strip().lower() + reasoning_max_tokens = self._coerce_token_value(openrouter_settings.get("reasoning_max_tokens", 0)) + if effective_reasoning_effort: + try: + ReasoningEffort(effective_reasoning_effort) + except (TypeError, ValueError): + get_logger().warning( + f"Ignoring invalid openrouter.reasoning_effort '{effective_reasoning_effort}'. " + f"Valid values: {[effort.value for effort in ReasoningEffort]}." + ) + effective_reasoning_effort = "" + if not effective_reasoning_effort: + if reasoning_max_tokens > 0 and inherited_reasoning_effort: + if inherited_reasoning_effort == "none": + get_logger().warning( + f"Ignoring config.reasoning_effort='{inherited_reasoning_effort}' because " + "openrouter.reasoning_max_tokens takes precedence." + ) + else: + get_logger().info( + "Using openrouter.reasoning_max_tokens over" + f" config.reasoning_effort='{inherited_reasoning_effort}'." + ) + elif reasoning_max_tokens <= 0: + effective_reasoning_effort = inherited_reasoning_effort or "" + + if effective_reasoning_effort: + clamped_effort = self._clamp_grok_reasoning_effort(model, effective_reasoning_effort) + if clamped_effort != effective_reasoning_effort: + get_logger().info( + f"Grok model {model} does not support reasoning_effort=" + f"'{effective_reasoning_effort}'; using '{clamped_effort}' instead." + ) + effective_reasoning_effort = clamped_effort + + # Preserve explicit disablement; otherwise keep effort and max_tokens + # mutually exclusive by preferring the token budget. + if effective_reasoning_effort == "none": + if reasoning_max_tokens > 0: + get_logger().warning( + "Ignoring openrouter.reasoning_max_tokens because " + "openrouter.reasoning_effort='none' disables reasoning." + ) + reasoning["enabled"] = False + elif reasoning_max_tokens > 0: + if effective_reasoning_effort: + get_logger().warning( + f"Ignoring openrouter.reasoning_effort='{effective_reasoning_effort}' because " + "openrouter.reasoning_max_tokens takes precedence." + ) + reasoning["max_tokens"] = reasoning_max_tokens + elif effective_reasoning_effort: + # OpenRouter uses xhigh for the max alias; extra_body bypasses + # LiteLLM's OpenRouter parameter mapping. + reasoning["effort"] = "xhigh" if effective_reasoning_effort == "max" else effective_reasoning_effort + if reasoning: + get_logger().info(f"Adding OpenRouter reasoning {reasoning} to model {model}.") + extra_body["reasoning"] = reasoning + + if extra_body: + kwargs["extra_body"] = extra_body + + max_tokens = self._coerce_token_value(openrouter_settings.get("max_tokens", 0)) + output_limit_param = ( + "max_completion_tokens" if "max_completion_tokens" in kwargs else "max_tokens" + ) + if max_tokens > 0: + existing = self._coerce_token_value(kwargs.get(output_limit_param, 0)) + kwargs[output_limit_param] = min(existing, max_tokens) if existing > 0 else max_tokens + effective_max_tokens = self._coerce_token_value(kwargs.get(output_limit_param, 0)) + effective_reasoning_max_tokens = self._coerce_token_value(reasoning.get("max_tokens", 0)) + effective_reasoning_effort = reasoning.get("effort") + if ( + model.startswith("openrouter/anthropic/") + and 0 < effective_max_tokens + and ( + 0 < effective_reasoning_max_tokens >= effective_max_tokens + or (effective_reasoning_effort and effective_max_tokens <= 1024) + ) + ): + minimum_reasoning_tokens = effective_reasoning_max_tokens or 1024 + get_logger().warning( + f"OpenRouter Anthropic max_tokens ({effective_max_tokens}) must be greater than " + f"the reasoning budget ({minimum_reasoning_tokens}) to leave output headroom." + ) + return kwargs + + @staticmethod + def _coerce_token_value(value) -> int: + """Mirror the request's permissive integer coercion without conversion failures.""" + try: + return int(value) + except (TypeError, ValueError, OverflowError): + return 0 + + def _claude_thinking_mode(self, model: str) -> str | None: + """Return the thinking mode selected by request construction for this model.""" + adaptive_model = self._model_uses_adaptive_thinking(model) + if adaptive_model and self._claude_thinking_controls["enable_claude_adaptive_thinking"]: + return "adaptive" + if ( + model in self.claude_extended_thinking_models + and self._claude_thinking_controls["enable_claude_extended_thinking"] + ): + return "unsupported_extended" if adaptive_model else "extended" + return None + + def _get_claude_extended_thinking_limits(self) -> tuple[int, int]: + """Validate and return the snapshotted extended-thinking budget and output cap.""" + extended_thinking_budget_tokens = self._claude_thinking_controls["extended_thinking_budget_tokens"] + extended_thinking_max_output_tokens = self._claude_thinking_controls["extended_thinking_max_output_tokens"] + + if not isinstance(extended_thinking_budget_tokens, int) or extended_thinking_budget_tokens <= 0: + raise ValueError( + f"extended_thinking_budget_tokens must be a positive integer, " + f"got {extended_thinking_budget_tokens}" + ) + if not isinstance(extended_thinking_max_output_tokens, int) or extended_thinking_max_output_tokens <= 0: + raise ValueError( + f"extended_thinking_max_output_tokens must be a positive integer, " + f"got {extended_thinking_max_output_tokens}" + ) + if extended_thinking_max_output_tokens < extended_thinking_budget_tokens: + raise ValueError( + f"extended_thinking_max_output_tokens ({extended_thinking_max_output_tokens}) must be greater than " + f"or equal to extended_thinking_budget_tokens ({extended_thinking_budget_tokens})" + ) + return extended_thinking_budget_tokens, extended_thinking_max_output_tokens + + def _resolve_output_token_limit(self, model: str, openrouter_model: str | None) -> int: + """Return the final positive output cap selected by PR-Agent request controls.""" + output_tokens = self._coerce_token_value(get_settings().config.get("max_output_tokens", 0)) + if self._claude_thinking_mode(model) == "extended": + _, output_tokens = self._get_claude_extended_thinking_limits() + + if openrouter_model: + openrouter_output_tokens = self._coerce_token_value(self._openrouter_controls.get("max_tokens", 0)) + if openrouter_output_tokens > 0: + output_tokens = ( + min(output_tokens, openrouter_output_tokens) + if output_tokens > 0 + else openrouter_output_tokens + ) + return output_tokens if output_tokens > 0 else 0 + + def get_output_token_limit(self, model: str) -> int: + """Return the output cap that this handler will request for the supplied model.""" + custom_llm_provider = self._custom_llm_provider + configured_deployment_id = self.deployment_id + routed_model = self._route_model_for_request(model, custom_llm_provider, configured_deployment_id) + completion_model = self._normalize_gpt5_model_for_request(routed_model, model, custom_llm_provider) + request_provider = ( + PROVIDER_SETTING_ALIASES.get(custom_llm_provider, custom_llm_provider) + if custom_llm_provider + else self._resolve_request_provider(routed_model) + ) + openrouter_model = self._canonical_openrouter_model(completion_model, request_provider) + return self._resolve_output_token_limit(completion_model, openrouter_model) + + def get_output_token_reserve(self, model: str, default_output_tokens: int) -> int: + """Return completion headroom to reserve while fitting a request prompt.""" + output_tokens = self.get_output_token_limit(model) + if output_tokens > 0: + return output_tokens + + default_output_tokens = self._coerce_token_value(default_output_tokens) + custom_llm_provider = self._custom_llm_provider + routed_model = self._route_model_for_request(model, custom_llm_provider, self.deployment_id) + request_provider = ( + PROVIDER_SETTING_ALIASES.get(custom_llm_provider, custom_llm_provider) + if custom_llm_provider + else self._resolve_request_provider(routed_model) + ) + openrouter_model = self._canonical_openrouter_model( + routed_model, request_provider + ) + if not openrouter_model: + return default_output_tokens + + reasoning_effort = str( + self._openrouter_controls.get("reasoning_effort", "") or "" + ).strip().lower() + reasoning_effort = self._clamp_grok_reasoning_effort( + openrouter_model, reasoning_effort + ) + reasoning_tokens = self._coerce_token_value( + self._openrouter_controls.get("reasoning_max_tokens", 0) + ) + if reasoning_effort == "none" or reasoning_tokens <= 0: + return default_output_tokens + return default_output_tokens + reasoning_tokens + + @staticmethod + def normalize_request_prompts(model: str, system_prompt: str, user_prompt: str) -> tuple[str, str]: + """Return the prompt strings that request construction will send.""" + if 'claude' in model and not system_prompt: + system_prompt = "No system prompt provided" + return system_prompt, user_prompt + + def build_request_messages( + self, + model: str, + system_prompt: str, + user_prompt: str, + *, + image_path: str | None = None, + ) -> list[dict]: + """Build the exact message payload for normalized prompt strings.""" + combine_prompts = ( + self._uses_user_message_only(model) + or get_settings().config.custom_reasoning_model + ) + if combine_prompts: + user_prompt = f"{system_prompt}\n\n\n{user_prompt}" + content = user_prompt + if image_path: + content = [ + {"type": "text", "text": user_prompt}, + {"type": "image_url", "image_url": {"url": image_path}}, + ] + return [{"role": "user", "content": content}] + + user_content = user_prompt + if image_path: + user_content = [ + {"type": "text", "text": user_prompt}, + {"type": "image_url", "image_url": {"url": image_path}}, + ] + return [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content}, + ] + + def _uses_user_message_only(self, model: str) -> bool: + """Recognize user-only models through any routed provider prefix.""" + return any( + model == registered_model or model.endswith(f"/{registered_model}") + for registered_model in self.user_message_only_models + ) + def _configure_claude_extended_thinking(self, model: str, kwargs: dict) -> dict: """ Configure Claude extended thinking parameters if applicable. @@ -554,16 +2112,9 @@ def _configure_claude_extended_thinking(self, model: str, kwargs: dict) -> dict: Returns: dict: Updated kwargs with extended thinking configuration """ - extended_thinking_budget_tokens = get_settings().config.get("extended_thinking_budget_tokens", 2048) - extended_thinking_max_output_tokens = get_settings().config.get("extended_thinking_max_output_tokens", 4096) - - # Validate extended thinking parameters - if not isinstance(extended_thinking_budget_tokens, int) or extended_thinking_budget_tokens <= 0: - raise ValueError(f"extended_thinking_budget_tokens must be a positive integer, got {extended_thinking_budget_tokens}") - if not isinstance(extended_thinking_max_output_tokens, int) or extended_thinking_max_output_tokens <= 0: - raise ValueError(f"extended_thinking_max_output_tokens must be a positive integer, got {extended_thinking_max_output_tokens}") - if extended_thinking_max_output_tokens < extended_thinking_budget_tokens: - raise ValueError(f"extended_thinking_max_output_tokens ({extended_thinking_max_output_tokens}) must be greater than or equal to extended_thinking_budget_tokens ({extended_thinking_budget_tokens})") + extended_thinking_budget_tokens, extended_thinking_max_output_tokens = ( + self._get_claude_extended_thinking_limits() + ) kwargs["thinking"] = { "type": "enabled", @@ -580,6 +2131,22 @@ def _configure_claude_extended_thinking(self, model: str, kwargs: dict) -> dict: return kwargs + @staticmethod + def _validated_model_name_list(setting_name: str) -> list[str]: + """Return a stripped config list of model names, or an empty list when malformed.""" + value = get_settings().config.get(setting_name, []) or [] + if not value: + return [] + if not isinstance(value, list) or not all( + isinstance(model, str) and model.strip() for model in value + ): + get_logger().warning( + f"Invalid {setting_name} in config; expected a list of model name strings. " + "Ignoring it and using the built-in Claude thinking model detection." + ) + return [] + return [model.strip() for model in value] + @staticmethod def _is_claude_adaptive_thinking_model(model: str) -> bool: """Return whether a Claude model requires the adaptive thinking API.""" @@ -589,10 +2156,17 @@ def _is_claude_adaptive_thinking_model(model: str) -> bool: normalized_model, ) is not None + def _model_uses_adaptive_thinking(self, model: str) -> bool: + """Return whether a model should receive the adaptive-thinking payload.""" + return ( + isinstance(model, str) + and model.strip() in self.claude_adaptive_thinking_models_override + ) or self._is_claude_adaptive_thinking_model(model) + def _configure_claude_adaptive_thinking(self, model: str, kwargs: dict) -> dict: """Configure thinking for Claude models that reject token budgets.""" kwargs["thinking"] = {"type": "adaptive"} - effort = get_settings().config.reasoning_effort + effort = self._default_reasoning_effort if effort in ("low", "medium", "high", "xhigh", "max"): kwargs["output_config"] = {"effort": effort} get_logger().info( @@ -626,7 +2200,7 @@ def capture_logs(message): if extra.get("pr_url") is not None: log_entry.update({"pr_url": extra["pr_url"]}) - # Append the log entry to the captured_logs list + # Append the captured request context. captured_extra.append(log_entry) # Adding the custom sink to Loguru @@ -747,83 +2321,112 @@ def _resolve_cache_control_injection_points(): raise ValueError("LITELLM.CACHE_CONTROL_INJECTION_POINTS must be a JSON/TOML array") return cache_control_injection_points + async def chat_completion(self, model: str, system: str, user: str, temperature: float = 0.2, img_path: str = None): + configured_deployment_id = self.deployment_id + return await self._chat_completion_with_retry( + model, + system, + user, + temperature, + img_path, + configured_deployment_id=configured_deployment_id, + ) + @retry( retry=retry_if_exception(_should_retry_same_model), stop=stop_after_attempt(MODEL_RETRIES), reraise=True, # surface the provider's error; RetryError hides the reason ) - async def chat_completion(self, model: str, system: str, user: str, temperature: float = 0.2, img_path: str = None): - # Serialize env-var mutation + Bedrock call for IMDS mode to prevent concurrent - # requests from interleaving os.environ credentials during asyncio.gather usage. + async def _chat_completion_with_retry( + self, + model: str, + system: str, + user: str, + temperature: float = 0.2, + img_path: str = None, + *, + configured_deployment_id: str | None, + ): # Validate config-derived kwargs before the try/except below, so a malformed value raises a # ValueError config error instead of being wrapped as openai.APIError and retried. cache_control_injection_points = self._resolve_cache_control_injection_points() client_retries = _configured_client_retries() - _bedrock_imds = self._aws_imds_mode and any( - provider in model for provider in ("bedrock/", "bedrock_mantle/") + custom_llm_provider = self._custom_llm_provider + user_model = model + routed_model = self._route_model_for_request(user_model, custom_llm_provider, configured_deployment_id) + completion_model = self._normalize_gpt5_model_for_request(routed_model, user_model, custom_llm_provider) + request_provider = ( + PROVIDER_SETTING_ALIASES.get(custom_llm_provider, custom_llm_provider) + if custom_llm_provider + else self._resolve_request_provider(routed_model) ) - async with (self._aws_bedrock_lock if _bedrock_imds else contextlib.nullcontext()): - if _bedrock_imds and not self._aws_imds_fell_back: - if not self._refresh_aws_imds_credentials() and self._aws_static_creds: - self._activate_static_aws_fallback() - self._aws_imds_fell_back = True + deployment_id = ( + configured_deployment_id + if request_provider == "azure" and not routed_model.startswith("azure_text/") + else None + ) + if img_path: + try: + # Finish external image I/O before validating mutable credential fallbacks. + r = await asyncio.to_thread( + requests.head, + img_path, + allow_redirects=True, + timeout=_IMAGE_HEAD_TIMEOUT_SECONDS, + ) + if r.status_code == 404: + error_msg = "The image link is not [alive](img_path).\nPlease repost the original image as a comment, and send the question again with 'quote reply' (see [instructions](https://docs.pr-agent.ai/tools/ask/#ask-on-images))." + get_logger().error(error_msg) + return f"{error_msg}", "error" + except Exception as e: + get_logger().error(f"Error fetching image: {img_path}", e) + return f"Error fetching image: {img_path}", "error" + + _aws_imds = self._should_use_aws_imds(request_provider) + async with self._snapshot_aws_request_credentials(_aws_imds) as ( + aws_request_credentials, + aws_can_fallback, + ): + # Resolve credentials before the retry-wrapped inference block. In + # particular, Azure AD refresh failures are authentication errors and + # should not be converted to retryable OpenAI API errors below. + provider_request_params = await self._get_provider_request_params_async( + routed_model, + provider=request_provider, + transport_provider=custom_llm_provider or None, + transport_model=deployment_id or completion_model, + aws_request_credentials=aws_request_credentials, + ) try: resp, finish_reason = None, None - deployment_id = self.deployment_id - # Capture the original model string so an explicit provider prefix in the - # user config (e.g. "azure/gpt-5...") can be preserved when the GPT-5 branch - # rebuilds the routed model name below. - user_model = model - # Capture the provider prefix before any rewriting below. Databricks auth/endpoint - # selection keys off this; rewriting (e.g. 'azure/' + model when Azure is enabled in - # a multi-provider config) would otherwise hide the 'databricks/' prefix and bypass - # the guards that keep Databricks on its own DATABRICKS_API_KEY/DATABRICKS_API_BASE. - is_databricks = model.startswith("databricks/") - # OpenRouter models keep their "openrouter/" prefix: __init__ already - # routed them to the OpenRouter api_key/api_base, and rewriting to - # "azure/openrouter/..." would both misroute the request and skip the - # OpenRouter controls block below (guarded by the same prefix). - is_openrouter = isinstance(model, str) and model.startswith("openrouter/") - if self.azure and not is_databricks and not is_openrouter: - model = 'azure/' + model - if 'claude' in model and not system: - system = "No system prompt provided" + # Azure mode rewrites only OpenAI models. Explicit non-OpenAI provider + # prefixes must remain intact in multi-provider configurations. + model = completion_model + openrouter_model = self._canonical_openrouter_model(model, request_provider) + normalized_system, user = self.normalize_request_prompts(model, system, user) + if normalized_system != system: get_logger().warning( "Empty system prompt for claude model. Adding a newline character to prevent OpenAI API error.") - messages = [{"role": "system", "content": system}, {"role": "user", "content": user}] - - if img_path: - try: - # check if the image link is alive - r = await asyncio.to_thread( - requests.head, - img_path, - allow_redirects=True, - timeout=_IMAGE_HEAD_TIMEOUT_SECONDS, - ) - if r.status_code == 404: - error_msg = "The image link is not [alive](img_path).\nPlease repost the original image as a comment, and send the question again with 'quote reply' (see [instructions](https://pr-agent-docs.codium.ai/tools/ask/#ask-on-images-using-the-pr-code-as-context))." - get_logger().error(error_msg) - return f"{error_msg}", "error" - except Exception as e: - get_logger().error(f"Error fetching image: {img_path}", e) - return f"Error fetching image: {img_path}", "error" - messages[1]["content"] = [{"type": "text", "text": messages[1]["content"]}, - {"type": "image_url", "image_url": {"url": img_path}}] + system = normalized_system + messages = self.build_request_messages( + model, + system, + user, + image_path=img_path, + ) thinking_kwargs_gpt5 = None + openrouter_reasoning_effort = None # Detect GPT-5 family regardless of provider prefix(es) on the model name. # Users sometimes put a provider prefix in config (e.g. "openai/gpt-5.1-codex-max"), # and Azure mode auto-prepends "azure/", which together can produce stacked prefixes # like "azure/openai/gpt-5...". Without normalization the GPT-5 path is skipped and # litellm rejects the request with UnsupportedParamsError for temperature=0.2. - model_base = model - while model_base.startswith(('openai/', 'azure/')): - model_base = model_base.removeprefix('openai/').removeprefix('azure/') - is_gpt6_astra = model_base.removesuffix('_thinking') == 'gpt-6-astra' - if model_base.startswith('gpt-5') or is_gpt6_astra: + is_gpt6_astra = self._is_gpt6_astra_model(model) + is_gpt5_model = self._is_gpt5_model(openrouter_model or model) + if is_gpt5_model or is_gpt6_astra: # Use configured reasoning_effort or default to MEDIUM - config_effort = get_settings().config.reasoning_effort + config_effort = self._default_reasoning_effort try: ReasoningEffort(config_effort) effort = config_effort @@ -838,46 +2441,64 @@ async def chat_completion(self, model: str, system: str, user: str, temperature: if is_gpt6_astra and effort in (ReasoningEffort.NONE.value, ReasoningEffort.MINIMAL.value): get_logger().info(f"GPT-6 Astra does not support reasoning_effort='{effort}'; using 'low'") effort = ReasoningEffort.LOW.value + elif not is_gpt6_astra and effort == ReasoningEffort.MAX.value: + # 'max' is this project's own alias for "the most reasoning available", + # already translated on the Grok and OpenRouter paths. GPT-5.2 and later + # name that level 'xhigh'; litellm reports supports_xhigh_reasoning_effort + # false for gpt-5 and gpt-5.1, so those are clamped to 'high' instead. + # GPT-6 Astra accepts 'max' natively and is left untouched. + lookup_model = model + while lookup_model.startswith(("openai/", "azure/")): + lookup_model = lookup_model.removeprefix("openai/").removeprefix("azure/") + lookup_model = lookup_model.removesuffix("_thinking") + try: + supports_xhigh = litellm.get_model_info(lookup_model).get( + "supports_xhigh_reasoning_effort" + ) + except Exception: + # An unknown model is not evidence that 'xhigh' is unsupported; keep it. + get_logger().debug( + f"litellm.get_model_info could not resolve model '{lookup_model}'" + ) + supports_xhigh = None + if supports_xhigh is False: + effort = ReasoningEffort.HIGH.value + get_logger().info( + f"{lookup_model} does not support reasoning_effort='xhigh'; " + "using 'high' for reasoning_effort='max'" + ) + else: + effort = ReasoningEffort.XHIGH.value + get_logger().info( + "GPT-5 models name their top reasoning level 'xhigh'; " + "using 'xhigh' for reasoning_effort='max'" + ) - thinking_kwargs_gpt5 = { - "reasoning_effort": effort, - "allowed_openai_params": ["reasoning_effort"], - } + if openrouter_model: + openrouter_reasoning_effort = effort + else: + thinking_kwargs_gpt5 = { + "reasoning_effort": effort, + "allowed_openai_params": ["reasoning_effort"], + } model_family = "GPT-6 Astra" if is_gpt6_astra else "GPT-5" get_logger().info(f"Using reasoning_effort='{effort}' for {model_family} model") - # Routing priority: Azure mode > explicit provider prefix in user config > openai/ - # default. This preserves an explicit "azure/" the user wrote in config even when - # self.azure is false, and avoids stacking when self.azure already added "azure/". - if self.azure: - provider_prefix = 'azure/' - elif user_model.startswith('azure/'): - provider_prefix = 'azure/' - elif user_model.startswith('openai/'): - provider_prefix = 'openai/' - else: - provider_prefix = 'openai/' - model = provider_prefix + model_base.replace('_thinking', '') # remove _thinking suffix - - # Currently, some models do not support a separate system and user prompts - if model in self.user_message_only_models or get_settings().config.custom_reasoning_model: + if self._uses_user_message_only(model) or get_settings().config.custom_reasoning_model: user = f"{system}\n\n\n{user}" system = "" get_logger().info(f"Using model {model}, combining system and user prompts") - messages = [{"role": "user", "content": user}] - # Build request kwargs after normalizing messages for the target model. - # Databricks selects its endpoint via the DATABRICKS_API_BASE env var; don't let an - # api_base configured by another provider (OpenRouter/Ollama/Azure AD/OpenAI) during - # __init__ override it in multi-provider configs. None lets LiteLLM read the env var. - api_base = os.environ.get("DATABRICKS_API_BASE") if is_databricks else self.api_base + # Build request kwargs after normalizing the model and messages so credentials and + # endpoints can be selected for the provider that will actually receive this call. kwargs = { - "model": model, - "deployment_id": deployment_id, - "messages": messages, - "timeout": get_settings().config.ai_timeout, - "api_base": api_base, - } + "model": model, + "messages": messages, + "timeout": get_settings().config.ai_timeout, + } + if deployment_id: + kwargs["deployment_id"] = deployment_id + kwargs.update(provider_request_params) # Caps the completion client's own per-call retries, which otherwise # multiply this handler's retry attempts. Parsed before the request @@ -893,89 +2514,88 @@ async def chat_completion(self, model: str, system: str, user: str, temperature: if thinking_kwargs_gpt5: kwargs.update(thinking_kwargs_gpt5) - if 'temperature' in kwargs: - del kwargs['temperature'] - - custom_llm_provider = str( - getattr(get_settings().litellm, "custom_llm_provider", "") or "" - ).strip().lower() - openrouter_reasoning_effort = None - reasoning_model = model.rsplit(":", 1)[0] if model.startswith("openrouter/") else model - # Add reasoning_effort if model supports it. Match the bare model - # id as well as any provider-prefixed form (e.g. - # "openrouter/google/gemini-2.5-pro", "gemini/gemini-2.5-pro"), so a - # configured reasoning_effort is not silently dropped for models the - # user references with a provider prefix. OpenRouter routing variants - # such as :nitro and :floor are stripped only for this membership test. - if any( - reasoning_model == m or reasoning_model.endswith("/" + m) - for m in self.support_reasoning_models + if is_gpt5_model or is_gpt6_astra: + kwargs.pop('temperature', None) + + reasoning_model = openrouter_model.rsplit(":", 1)[0] if openrouter_model else model + # Add reasoning_effort if the model supports it. Support comes from + # litellm's bundled model metadata (probed over suffix forms so bare, + # provider-prefixed, and OpenRouter :nitro/:floor variants all resolve), + # the GROK_REASONING_EFFORT_LEVELS registry (source of truth for the Grok + # ids, which older bundled maps do not all list), and + # config.additional_reasoning_effort_models as the operator escape hatch + # for endpoints litellm does not know. Claude models are excluded because + # their reasoning is driven by the dedicated + # enable_claude_extended/adaptive_thinking settings. Skip GPT-5/GPT-6 + # Astra here so a config-registered model cannot overwrite the + # reasoning_effort normalization of its dedicated branch. + if not (is_gpt5_model or is_gpt6_astra) and ( + self._grok_reasoning_levels_for(reasoning_model) is not None + or not self._is_claude_family_model(reasoning_model) + and self._litellm_supports_reasoning(reasoning_model) + or any( + reasoning_model == m or reasoning_model.endswith("/" + m) + for m in self.additional_reasoning_effort_models + ) ): - config_effort = get_settings().config.reasoning_effort - try: - ReasoningEffort(config_effort) - reasoning_effort = config_effort - except (ValueError, TypeError): - reasoning_effort = ReasoningEffort.MEDIUM.value - if config_effort is not None: - get_logger().warning( - f"Invalid reasoning_effort '{config_effort}' in config. " - f"Using default '{reasoning_effort}'. Valid values: {[e.value for e in ReasoningEffort]}" - ) - - clamped_effort = self._clamp_grok_reasoning_effort(model, reasoning_effort) - if clamped_effort != reasoning_effort: - get_logger().info( - f"Grok model {model} does not support reasoning_effort='{reasoning_effort}'; " - f"using '{clamped_effort}' instead." - ) - reasoning_effort = clamped_effort + config_effort = self._default_reasoning_effort + reasoning_effort = self._resolve_reasoning_effort(openrouter_model or model, config_effort) - if model.startswith("openrouter/"): - # LiteLLM 1.98.0 rejects top-level reasoning_effort for some - # OpenRouter model IDs it does not mark as reasoning-capable; - # defer to OpenRouter's unified reasoning object below. + if openrouter_model: + # LiteLLM rejects top-level reasoning_effort for some OpenRouter + # model IDs it does not mark as reasoning-capable; defer to + # OpenRouter's unified reasoning object below. openrouter_reasoning_effort = reasoning_effort else: get_logger().info(f"Adding reasoning_effort with value {reasoning_effort} to model {model}.") kwargs["reasoning_effort"] = reasoning_effort - if self._grok_reasoning_levels_for(model): - try: - supported_params = litellm.get_supported_openai_params( - model=model, - custom_llm_provider=custom_llm_provider or None, - ) or [] - except Exception: - supported_params = [] - # LiteLLM 1.98.0 omits reasoning_effort for grok-build-latest - # and OpenAI-compatible gateway-prefixed Grok IDs. - if "reasoning_effort" not in supported_params: - kwargs["allowed_openai_params"] = ["reasoning_effort"] + # Whitelist reasoning_effort through allowed_openai_params when + # LiteLLM omits it from the params it reports for unknown or + # OpenAI-compatible gateway-prefixed model IDs. Merge into any + # existing allowed_openai_params instead of overwriting it. + try: + supported_params = litellm.get_supported_openai_params( + model=model, + custom_llm_provider=custom_llm_provider or None, + ) or [] + except Exception: + supported_params = [] + if "reasoning_effort" not in supported_params: + allowed_params = kwargs.get("allowed_openai_params") or [] + if "reasoning_effort" not in allowed_params: + kwargs["allowed_openai_params"] = [*allowed_params, "reasoning_effort"] # https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking - if self._is_claude_adaptive_thinking_model(model) and get_settings().config.get( - "enable_claude_adaptive_thinking", False): + adaptive_thinking_enabled = self._claude_thinking_controls["enable_claude_adaptive_thinking"] + extended_thinking_enabled = self._claude_thinking_controls["enable_claude_extended_thinking"] + claude_thinking_mode = self._claude_thinking_mode(model) + if claude_thinking_mode == "adaptive": kwargs = self._configure_claude_adaptive_thinking(model, kwargs) - elif ( - model in self.claude_extended_thinking_models - and get_settings().config.get("enable_claude_extended_thinking", False) - ): - if self._is_claude_adaptive_thinking_model(model): - get_logger().warning( - f"Skipping extended thinking for {model}: adaptive-only models reject " - f"budget_tokens. Enable config.enable_claude_adaptive_thinking instead." + elif claude_thinking_mode == "extended": + kwargs = self._configure_claude_extended_thinking(model, kwargs) + elif claude_thinking_mode == "unsupported_extended": + get_logger().warning( + f"Skipping extended thinking for {model}: adaptive-only models reject " + f"budget_tokens. Enable config.enable_claude_adaptive_thinking instead." + ) + elif adaptive_thinking_enabled or extended_thinking_enabled: + message = ( + f"No thinking configuration applied for model {model}: adaptive thinking " + f"requires a recognized claude 5 model name in the id and extended " + f"thinking requires exact membership in claude_extended_thinking_models." + ) + if "arn:aws:bedrock:" in model: + message += ( + " For a Bedrock inference profile, add the ARN to " + "config.claude_adaptive_thinking_models_override." ) - else: - kwargs = self._configure_claude_extended_thinking(model, kwargs) + get_logger().warning(message) # Optional output token limit; 0 = unset. Without max_tokens some # providers apply a low service-side default (Bedrock Converse: 4096, # which reasoning can fully consume, returning empty content). # setdefault keeps the extended-thinking limit authoritative. - try: - max_output_tokens = int(get_settings().config.get("max_output_tokens", 0)) - except (TypeError, ValueError): - max_output_tokens = 0 + max_output_tokens = self._resolve_output_token_limit(model, openrouter_model) if max_output_tokens > 0: output_limit_param = "max_completion_tokens" if is_gpt6_astra else "max_tokens" kwargs.setdefault(output_limit_param, max_output_tokens) @@ -993,16 +2613,6 @@ async def chat_completion(self, model: str, system: str, user: str, temperature: if self.repetition_penalty: kwargs["repetition_penalty"] = self.repetition_penalty - #Added support for extra_headers while using litellm to call underlying model, via a api management gateway, would allow for passing custom headers for security and authorization - if get_settings().get("LITELLM.EXTRA_HEADERS", None): - try: - litellm_extra_headers = json.loads(get_settings().litellm.extra_headers) - if not isinstance(litellm_extra_headers, dict): - raise ValueError("LITELLM.EXTRA_HEADERS must be a JSON object") - except json.JSONDecodeError as e: - raise ValueError(f"LITELLM.EXTRA_HEADERS contains invalid JSON: {str(e)}") - kwargs["extra_headers"] = litellm_extra_headers - # Support for custom OpenAI body fields (e.g., Flex Processing) kwargs = _process_litellm_extra_body(kwargs) @@ -1021,7 +2631,7 @@ async def chat_completion(self, model: str, system: str, user: str, temperature: supported_params = [] if "user" in supported_params: kwargs["user"] = request_user - elif isinstance(model, str) and model.startswith("openrouter/"): + elif openrouter_model: # LiteLLM's OpenRouter transformation does not forward the # standard "user" parameter; extra_body reaches the # OpenAI-compatible request body verbatim. @@ -1052,132 +2662,20 @@ async def chat_completion(self, model: str, system: str, user: str, temperature: # Classic `bedrock/` calls use model_id for Bedrock Runtime inference profiles. # Bedrock Mantle uses Projects, so `bedrock_mantle/` intentionally omits it. - model_id = get_settings().get("litellm.model_id") - if model_id and 'bedrock/' in model: - kwargs["model_id"] = model_id - get_logger().info(f"Using Bedrock custom inference profile: {model_id}") + bedrock_model_id = getattr(self, "_bedrock_model_id", None) + if bedrock_model_id and request_provider == "bedrock": + kwargs["model_id"] = bedrock_model_id + get_logger().info(f"Using Bedrock custom inference profile: {bedrock_model_id}") # OpenRouter provider routing, reasoning control and output cap. # Registered reasoning models inherit config.reasoning_effort when # no OpenRouter-specific effort or token budget is configured. - if isinstance(model, str) and model.startswith("openrouter/"): - openrouter_settings = get_settings().get("openrouter", {}) - extra_body = kwargs.get("extra_body") or {} - - # Normalize operator-controlled config: Dynaconf/env overrides can - # arrive as strings (AUTO_CAST_FOR_DYNACONF is disabled), so coerce - # defensively instead of trusting the declared types. - def _as_list(value): - if isinstance(value, (list, tuple)): - return [str(v).strip() for v in value if str(v).strip()] - if isinstance(value, str): - return [v.strip() for v in value.split(",") if v.strip()] - return [] - - def _as_bool(value, default=True): - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in ("1", "true", "yes", "on") - return default - - def _as_int(value): - try: - return int(value) - except (TypeError, ValueError): - return 0 - - provider_only = _as_list(openrouter_settings.get("provider_only", [])) - provider_order = _as_list(openrouter_settings.get("provider_order", [])) - if provider_only: - extra_body.setdefault("provider", {})["only"] = provider_only - elif provider_order: - provider = extra_body.setdefault("provider", {}) - provider["order"] = provider_order - provider["allow_fallbacks"] = _as_bool(openrouter_settings.get("allow_fallbacks", True)) - - reasoning = {} - effective_reasoning_effort = str( - openrouter_settings.get("reasoning_effort", "") or "" - ).strip().lower() - reasoning_max_tokens = _as_int(openrouter_settings.get("reasoning_max_tokens", 0)) - if effective_reasoning_effort: - try: - ReasoningEffort(effective_reasoning_effort) - except (TypeError, ValueError): - get_logger().warning( - f"Ignoring invalid openrouter.reasoning_effort '{effective_reasoning_effort}'. " - f"Valid values: {[effort.value for effort in ReasoningEffort]}." - ) - effective_reasoning_effort = "" - if not effective_reasoning_effort: - if reasoning_max_tokens > 0 and openrouter_reasoning_effort: - if openrouter_reasoning_effort == "none": - get_logger().warning( - f"Ignoring config.reasoning_effort='{openrouter_reasoning_effort}' because " - "openrouter.reasoning_max_tokens takes precedence." - ) - else: - get_logger().info( - "Using openrouter.reasoning_max_tokens over" - f" config.reasoning_effort='{openrouter_reasoning_effort}'." - ) - elif reasoning_max_tokens <= 0: - effective_reasoning_effort = openrouter_reasoning_effort or "" - - if effective_reasoning_effort: - clamped_effort = self._clamp_grok_reasoning_effort(model, effective_reasoning_effort) - if clamped_effort != effective_reasoning_effort: - get_logger().info( - f"Grok model {model} does not support reasoning_effort=" - f"'{effective_reasoning_effort}'; using '{clamped_effort}' instead." - ) - effective_reasoning_effort = clamped_effort - - # Preserve explicit disablement; otherwise keep effort and - # max_tokens mutually exclusive by preferring the token budget. - if effective_reasoning_effort == "none": - if reasoning_max_tokens > 0: - get_logger().warning( - "Ignoring openrouter.reasoning_max_tokens because " - "openrouter.reasoning_effort='none' disables reasoning." - ) - reasoning["enabled"] = False - elif reasoning_max_tokens > 0: - if effective_reasoning_effort: - get_logger().warning( - f"Ignoring openrouter.reasoning_effort='{effective_reasoning_effort}' because " - "openrouter.reasoning_max_tokens takes precedence." - ) - reasoning["max_tokens"] = reasoning_max_tokens - elif effective_reasoning_effort: - # OpenRouter uses xhigh for the max alias; extra_body bypasses - # LiteLLM's OpenRouter parameter mapping. - reasoning["effort"] = ( - "xhigh" if effective_reasoning_effort == "max" else effective_reasoning_effort - ) - if reasoning: - get_logger().info(f"Adding OpenRouter reasoning {reasoning} to model {model}.") - extra_body["reasoning"] = reasoning - - if extra_body: - kwargs["extra_body"] = extra_body - - max_tokens = _as_int(openrouter_settings.get("max_tokens", 0)) - if max_tokens > 0: - existing = _as_int(kwargs.get("max_tokens", 0)) - kwargs["max_tokens"] = min(existing, max_tokens) if existing > 0 else max_tokens - effective_max_tokens = _as_int(kwargs.get("max_tokens", 0)) - effective_reasoning_max_tokens = _as_int(reasoning.get("max_tokens", 0)) - if ( - model.startswith("openrouter/anthropic/") - and effective_reasoning_max_tokens > 0 - and 0 < effective_max_tokens <= effective_reasoning_max_tokens - ): - get_logger().warning( - f"OpenRouter Anthropic max_tokens ({effective_max_tokens}) must be greater than " - f"reasoning_max_tokens ({effective_reasoning_max_tokens}) to leave output headroom." - ) + if openrouter_model: + kwargs = self._apply_openrouter_request_controls( + openrouter_model, + kwargs, + openrouter_reasoning_effort, + ) get_logger().debug("Prompts", artifact={"system": system, "user": user}) @@ -1185,14 +2683,6 @@ def _as_int(value): get_logger().info(f"\nSystem prompt:\n{system}") get_logger().info(f"\nUser prompt:\n{user}") - # Inject api_key to the call. This key is populated during init by providers - # like Groq, SambaNova, XAI, Azure AD, and OpenRouter. Skip if None or placeholder. - # Databricks authenticates via the DATABRICKS_API_KEY/DATABRICKS_API_BASE env vars, - # so don't override it with another provider's key in multi-provider configs. - if (litellm.api_key and litellm.api_key != DUMMY_LITELLM_API_KEY - and not is_databricks): - kwargs["api_key"] = litellm.api_key - # Optional fixed provider override, so a raw hosted model id reaches the # provider unchanged instead of being rewritten by LiteLLM's prefix inference. if custom_llm_provider: @@ -1205,12 +2695,21 @@ def _as_int(value): get_logger().error(f"Rate limit error during LLM inference: {e}") raise except openai.APIError as e: - if _bedrock_imds and not self._aws_imds_fell_back and self._aws_static_creds: - self._activate_static_aws_fallback() - # Retry immediately while still holding the lock so that the - # env-var swap is fully visible to this call. Letting @retry - # handle the retry would release the lock between attempts, - # allowing a concurrent coroutine to overwrite os.environ. + if aws_can_fallback: + if not self._aws_imds_fell_back: + self._activate_static_aws_fallback() + fallback_credentials = dict(self._aws_active_creds) + request_region = kwargs.get("aws_region_name") + for key in AWS_REQUEST_CREDENTIAL_KEYS: + kwargs.pop(key, None) + kwargs.update(fallback_credentials) + if request_region and ( + request_provider == "bedrock_mantle" + or (request_provider == "bedrock" and _get_bedrock_model_region( + kwargs["model"], kwargs.get("model_id"), + )) + ): + kwargs["aws_region_name"] = request_region resp, finish_reason, response_obj = await self._get_completion(**kwargs) else: get_logger().warning(f"Error during LLM inference: {e}") @@ -1241,6 +2740,47 @@ def _as_int(value): return resp, finish_reason + async def probe_completion(self, model: str, *, max_tokens: int = 10, timeout: int = 10, _completion=None) -> None: + """Preserve the single-call health probe using request-local credentials.""" + custom_llm_provider = self._custom_llm_provider + configured_deployment_id = self.deployment_id + routed_model = self._route_model_for_request(model, custom_llm_provider, configured_deployment_id) + request_provider = ( + PROVIDER_SETTING_ALIASES.get(custom_llm_provider, custom_llm_provider) + if custom_llm_provider + else self._resolve_request_provider(routed_model) + ) + deployment_id = ( + configured_deployment_id + if request_provider == "azure" and not routed_model.startswith("azure_text/") + else None + ) + async with self._snapshot_aws_request_credentials(self._should_use_aws_imds(request_provider)) as ( + aws_request_credentials, + _, + ): + kwargs = { + "model": self._normalize_gpt5_model_for_request(routed_model, model, custom_llm_provider), + "messages": [{"role": "system", "content": "Say ping"}], + "max_tokens": max_tokens, + "timeout": timeout, + } + if deployment_id: + kwargs["deployment_id"] = deployment_id + kwargs.update(await self._get_provider_request_params_async( + routed_model, + provider=request_provider, + transport_provider=custom_llm_provider or None, + transport_model=deployment_id or kwargs["model"], + aws_request_credentials=aws_request_credentials, + )) + if custom_llm_provider: + kwargs["custom_llm_provider"] = custom_llm_provider + if self._bedrock_model_id and request_provider == "bedrock": + kwargs["model_id"] = self._bedrock_model_id + kwargs["model"] = normalize_litellm_model(kwargs["model"], custom_llm_provider) + await self._acompletion(_completion=_completion, **kwargs) + async def _get_completion(self, **kwargs): """ Wrapper that automatically handles streaming for required models. @@ -1250,32 +2790,25 @@ async def _get_completion(self, **kwargs): # Double the prefix so LiteLLM strips its provider prefix but preserves # OpenRouter's native router ID; leave other explicit providers unchanged. kwargs["model"] = normalize_litellm_model(model, custom_llm_provider) - api_base_value = kwargs.get("api_base") - api_base = api_base_value.strip().lower() if isinstance(api_base_value, str) else "" - force_streaming = ( - bool(custom_llm_provider) - and custom_llm_provider == self.force_streaming_provider - and bool(self.force_streaming_api_base_substrings) - and any(substring in api_base for substring in self.force_streaming_api_base_substrings) - ) + force_streaming = self._force_streaming_for_request(custom_llm_provider, kwargs.get("api_base")) # Some OpenAI-compatible endpoints can return an empty-string # finish_reason on non-streaming responses, which LiteLLM rejects during # response normalization. Streaming avoids that conversion path. - if model in self.streaming_required_models or force_streaming: + if self._requires_streaming(model) or force_streaming: kwargs["stream"] = True kwargs["stream_options"] = {"include_usage": True} - if force_streaming and model not in self.streaming_required_models: + if force_streaming and not self._requires_streaming(model): get_logger().info( f"Using streaming mode for model {model} " "due to OpenAI-compatible endpoint compatibility" ) else: get_logger().info(f"Using streaming mode for model {model}") - response = await acompletion(**kwargs) + response = await self._acompletion(**kwargs) return await _handle_streaming_response(response, model=model) else: - response = await acompletion(**kwargs) + response = await self._acompletion(**kwargs) if response is None or len(response["choices"]) == 0: raise openai.APIError( f"No choices in model response from {model}", @@ -1293,3 +2826,261 @@ async def _get_completion(self, **kwargs): body=None, ) return content, finish_reason, response + + async def _acompletion( + self, _completion=None, _azure_oidc_guard=False, _azure_ad_guard=False, _raw_api_key_guard=False, **kwargs, + ): + """Call LiteLLM with any provider compatibility context scoped to this task.""" + _completion = _completion or acompletion + custom_llm_provider = str(kwargs.get("custom_llm_provider") or "").strip().lower() + provider = ( + PROVIDER_SETTING_ALIASES.get(custom_llm_provider, custom_llm_provider) + if custom_llm_provider + else self._resolve_request_provider(kwargs.get("model")) + ) + transport = ( + custom_llm_provider or self._resolve_request_transport_provider(kwargs.get("model")) or provider + ) + transport_model = kwargs.get("deployment_id") or kwargs.get("model") + azure_ad_token = kwargs.get("azure_ad_token") + oidc_selector = azure_ad_token if isinstance(azure_ad_token, str) and azure_ad_token.startswith("oidc/") else None + companion_auth = ( + self._uses_captured_azure_companion_auth(provider) + and not _is_cloudflare_gateway(kwargs.get("api_base")) + ) + if provider == "azure_ai" and companion_auth: + native_transport, native_model = _azure_ai_native_transport(transport, transport_model) + if native_transport == "azure": + # Keep Azure AI's captured credentials and public kwargs; only + # select the bridge matching LiteLLM's downstream transport. + provider = transport = native_transport + transport_model = native_model + _azure_ad_guard = _azure_ad_guard or _raw_api_key_guard + ordinary_sdk_auth = ( + provider == "azure" and (azure_ad_token or companion_auth) and not oidc_selector + and not _is_cloudflare_gateway(kwargs.get("api_base")) + and _request_local_openai_headers(transport, model=transport_model) is not None + ) + if provider == "azure" and azure_ad_token and kwargs.get("api_key") is not None: + captured_key = self._captured_api_key("azure") + model = kwargs.get("model", "") + headers = dict(kwargs.get("headers") or {}) + guard_key = kwargs.get("api_key") == DUMMY_LITELLM_API_KEY and (self._azure_ad or not captured_key) + cloudflare_key_branch = _is_cloudflare_gateway(kwargs.get("api_base")) + if ( + (guard_key or cloudflare_key_branch) + and (not oidc_selector or not _azure_oidc_guard or cloudflare_key_branch) + and not (ordinary_sdk_auth and _azure_ad_guard) + and (cloudflare_key_branch or (transport != "azure_text" and not model.startswith("azure_text/"))) + and not _uses_openai_responses_transport(transport_model, transport) + ): + for canonical in ("Authorization", "api-key"): + matches = [name for name in headers if name.lower() == canonical.lower()] + if matches: + value = headers[matches[-1]] + for name in matches: + del headers[name] + headers[canonical] = value + if cloudflare_key_branch and (not guard_key or "api-key" in headers): + # Preserve the initial SDK alias/key choice, including its + # absent header; a later alias must not replace this identity. + sdk_token = self._azure_sdk_ad_token + headers.setdefault("Authorization", f"Bearer {sdk_token}" if sdk_token else openai.Omit()) + headers.setdefault("api-key", openai.Omit() if sdk_token else kwargs["api_key"]) + elif guard_key: + # Azure v1 and Cloudflare can prefer the guard key over AD + # auth. Keep the guard, but choose auth through SDK headers. + if "Authorization" not in headers: + if azure_ad_token.startswith("oidc/"): + # Keep LiteLLM's pinned OIDC exchange/cache semantics; + # the selector must never become a Bearer credential. + azure_ad_token = await asyncio.to_thread( + _exchange_azure_oidc_token, + azure_ad_token, + dict(self._azure_oidc_environment), + ) + kwargs["azure_ad_token"] = azure_ad_token + headers["Authorization"] = f"Bearer {azure_ad_token}" + headers.setdefault("api-key", openai.Omit()) + kwargs["headers"] = headers + anthropic_token = None + vertex_token = None + vertex_active_token = None + vertex_aws_token = None + vertex_default_token = None + databricks_token = None + if provider == "vertex_ai": + _install_vertex_executable_guard() + adc_snapshot = self._vertex_gac_adc or self._vertex_default_adc + if adc_snapshot is not None: + _install_vertex_default_adc_bridge() + kwargs.setdefault("vertex_credentials", adc_snapshot["cache_key"]) + vertex_credentials = kwargs.get("vertex_credentials") + if adc_snapshot is not None and vertex_credentials == adc_snapshot["cache_key"]: + vertex_credentials = adc_snapshot["info"] + if isinstance(vertex_credentials, str): + try: + vertex_credentials = json.loads(vertex_credentials) + except (ValueError, TypeError): + vertex_credentials = None + if isinstance(vertex_credentials, dict): + source = vertex_credentials.get("credential_source") + if ( + vertex_credentials.get("type") == "external_account" + and isinstance(source, dict) + and "executable" in source + ): + raise ValueError("Vertex executable credentials are incompatible with request isolation") + _install_vertex_impersonated_credentials_bridge() + if vertex_credentials.get("type") == "external_account": + _install_vertex_wif_project_bridge() + vertex_token = _vertex_request_credentials.set(vertex_credentials) + vertex_active_token = _vertex_request_active.set(True) + vertex_aws_token = _vertex_request_aws_environment.set(self._vertex_aws_environment) + vertex_default_token = _vertex_request_default_adc.set(adc_snapshot) + if provider == "databricks": + captured_key = self._captured_api_key("databricks") + keyless = kwargs.get("api_key") == DUMMY_LITELLM_API_KEY and not captured_key + if keyless: + _install_databricks_keyless_bridge() + databricks_token = _databricks_request_keyless.set(keyless) + if provider == "anthropic": + _install_anthropic_auth_token_bridge() + captured_key = self._captured_api_key("anthropic") + anthropic_token = _anthropic_request_auth_token.set({ + "auth_token": getattr(self, "_anthropic_auth_token", None), + "generated_guard": kwargs.get("api_key") == DUMMY_LITELLM_API_KEY and not captured_key, + }) + sdk_token = None + if _request_local_openai_headers(transport, model=transport_model) is not None: + if kwargs.get("client") is not None: + raise ValueError("Externally supplied SDK clients cannot guarantee request header isolation") + _install_sdk_header_bridge() + explicit_headers = _merge_sdk_headers(kwargs.get("headers") or {}, kwargs.get("extra_headers") or {}) + _check_sdk_marker_collision(explicit_headers) + sdk_token = _sdk_request_headers.set({ + **self._sdk_header_defaults, + "provider": provider, + "explicit_headers": explicit_headers, + }) + oidc_context = None + ad_responses_context = None + raw_key_context = None + raw_auth_context = None + try: + # A tokenless context must not match an unrelated nested None token. + oidc_context = _azure_oidc_request.set(None) + ad_responses_context = _azure_ad_responses_request.set(None) + raw_key_provider = None + if provider == "azure_ai" and companion_auth and _raw_api_key_guard: + _install_raw_api_key_guard_override_bridge(transport) + raw_key_provider = transport + if _raw_api_key_guard and any( + name.lower() == "authorization" for name in (kwargs.get("headers") or {}) + ): + _install_raw_api_key_guard_bridge() + _install_raw_api_key_guard_override_bridge(transport) + raw_key_provider = transport + snapshot = getattr(self, "_raw_guard_auth_snapshot", {}) + if ( + kwargs.get("api_key") == DUMMY_LITELLM_API_KEY + and not snapshot.get("generic_key", True) + and JSONProviderRegistry.supports_responses_api(transport) + and _uses_openai_responses_transport(transport_model, transport) + ): + headers = dict(kwargs["headers"]) + authorization = [name for name in headers if name.lower() == "authorization"] + if len(authorization) == 1: + # Native JSON Responses merges explicit headers after + # creating its guard header, using case-sensitive keys. + headers["Authorization"] = headers.pop(authorization[0]) + kwargs["headers"] = headers + # A nested non-guard call must not inherit an outer guard's provenance. + raw_key_context = _raw_api_key_guard_provider.set(raw_key_provider) + raw_auth_context = _raw_api_key_guard_auth.set( + getattr(self, "_raw_guard_auth_snapshot", None) if raw_key_provider else None + ) + if ( + provider == "azure" and (azure_ad_token or companion_auth) and not oidc_selector + and _uses_openai_responses_transport(transport_model, transport) + ) or (provider == "azure_ai" and companion_auth): + _install_azure_oidc_bridge() + environment = dict(self._azure_oidc_environment) + _azure_ad_responses_request.set({ + "dispatch_token": azure_ad_token, + "generated_guard": _azure_ad_guard or (provider == "azure_ai" and _raw_api_key_guard), + "companion": companion_auth, + "environment": environment, + "auth_environment": {**environment, **self._azure_oidc_auth_environment}, + "selector_hash": hashlib.sha256(azure_ad_token.encode()).hexdigest() + if isinstance(azure_ad_token, str) else None, + "identity_hash": hashlib.sha256( + json.dumps( + {**environment, **self._azure_oidc_auth_environment}, sort_keys=True + ).encode() + ).hexdigest(), + }) + if provider == "azure" and (oidc_selector or ordinary_sdk_auth): + _install_azure_oidc_bridge() + environment = dict(self._azure_oidc_environment) + if set(environment) != set(AZURE_OIDC_ENV_VARS): + raise RuntimeError("Azure OIDC exchange snapshot is incomplete") + auth_environment = {**environment, **self._azure_oidc_auth_environment} + # A missing selector scopes ordinary AD to SDK identity/cache + # handling without entering the OIDC exchange or resolver. + _azure_oidc_request.set({ + "selector": oidc_selector, + "dispatch_token": azure_ad_token, + "generated_guard": _azure_oidc_guard or _azure_ad_guard, + "environment": environment, + "auth_environment": auth_environment, + "selector_hash": hashlib.sha256(azure_ad_token.encode()).hexdigest() + if isinstance(azure_ad_token, str) else None, + "identity_hash": hashlib.sha256(json.dumps(auth_environment, sort_keys=True).encode()).hexdigest(), + }) + if provider != "bedrock_mantle": + return await _completion(**kwargs) + request_credentials = { + key: kwargs[key] + for key in BEDROCK_MANTLE_REQUEST_CONTEXT_KEYS + if key in kwargs + } + guard_generic_api_key = kwargs.get("api_key") == DUMMY_LITELLM_API_KEY + if not request_credentials and not guard_generic_api_key: + return await _completion(**kwargs) + if BedrockMantleAuthMixin is None: + raise RuntimeError("The installed LiteLLM version does not provide the Bedrock Mantle signer bridge") + # LiteLLM stores aws_* completion kwargs in litellm_params, while the + # Bedrock Mantle signer only receives optional_params. Install the bridge + # only when this provider is used, then scope credentials to this task. + _install_bedrock_mantle_signer_bridge() + credentials_token = _bedrock_mantle_request_credentials.set(request_credentials) + block_bearer_token = _bedrock_mantle_block_bearer.set(guard_generic_api_key) + try: + return await _completion(**kwargs) + finally: + _bedrock_mantle_block_bearer.reset(block_bearer_token) + _bedrock_mantle_request_credentials.reset(credentials_token) + finally: + if raw_auth_context is not None: + _raw_api_key_guard_auth.reset(raw_auth_context) + if raw_key_context is not None: + _raw_api_key_guard_provider.reset(raw_key_context) + if ad_responses_context is not None: + _azure_ad_responses_request.reset(ad_responses_context) + if oidc_context is not None: + _azure_oidc_request.reset(oidc_context) + if sdk_token is not None: + _sdk_request_headers.reset(sdk_token) + if databricks_token is not None: + _databricks_request_keyless.reset(databricks_token) + if vertex_default_token is not None: + _vertex_request_default_adc.reset(vertex_default_token) + if vertex_aws_token is not None: + _vertex_request_aws_environment.reset(vertex_aws_token) + if vertex_active_token is not None: + _vertex_request_active.reset(vertex_active_token) + if vertex_token is not None: + _vertex_request_credentials.reset(vertex_token) + if anthropic_token is not None: + _anthropic_request_auth_token.reset(anthropic_token) diff --git a/pr_agent/algo/ai_handlers/litellm_helpers.py b/pr_agent/algo/ai_handlers/litellm_helpers.py index 48446dbb62..569049d106 100644 --- a/pr_agent/algo/ai_handlers/litellm_helpers.py +++ b/pr_agent/algo/ai_handlers/litellm_helpers.py @@ -152,24 +152,31 @@ def get_repetition_penalty(): return penalty -def _get_azure_ad_token(): +def _get_azure_ad_credential(settings): + """Create an Azure AD credential for one handler/request context.""" + from azure.identity import ClientSecretCredential + + return ClientSecretCredential( + tenant_id=settings.azure_ad.tenant_id, + client_id=settings.azure_ad.client_id, + client_secret=settings.azure_ad.client_secret, + ) + + +def _get_azure_ad_token(credential): """ Generates an access token using Azure AD credentials from settings. Returns: str: The access token """ - from azure.identity import ClientSecretCredential + if credential is None: + raise ValueError("Azure AD credential is required for request-local token resolution") try: - credential = ClientSecretCredential( - tenant_id=get_settings().azure_ad.tenant_id, - client_id=get_settings().azure_ad.client_id, - client_secret=get_settings().azure_ad.client_secret - ) # Get token for Azure OpenAI service token = credential.get_token("https://cognitiveservices.azure.com/.default") return token.token except Exception as e: - get_logger().error(f"Failed to get Azure AD token: {e}") + get_logger().error(f"Failed to get Azure AD token: {type(e).__name__}") raise diff --git a/pr_agent/algo/artifacts.py b/pr_agent/algo/artifacts.py index d69a9c4f00..1c12eb0073 100644 --- a/pr_agent/algo/artifacts.py +++ b/pr_agent/algo/artifacts.py @@ -2,6 +2,8 @@ from pathlib import Path from typing import Optional +import dynaconf + from pr_agent.config_loader import get_settings from pr_agent.log import get_logger @@ -149,7 +151,7 @@ def inject_artifact_context() -> None: separator = "\n======\n\n" for key in get_settings(): setting = get_settings().get(key) - if str(type(setting)) == "": + if isinstance(setting, dynaconf.DataDict): if key.lower() in target_tools and hasattr(setting, 'extra_instructions'): extra_instructions = str(setting.extra_instructions or "") if artifact_text not in extra_instructions: diff --git a/pr_agent/algo/cli_args.py b/pr_agent/algo/cli_args.py index e5551efbfb..cceac7bdfe 100644 --- a/pr_agent/algo/cli_args.py +++ b/pr_agent/algo/cli_args.py @@ -1,16 +1,22 @@ from base64 import b64decode -from pr_agent.config_security import REPO_HOST_ONLY_KEYS_BY_SECTION, REPO_OVERRIDABLE_KEYS_BY_HOST_SECTION +from pr_agent.config_security import ( + CLI_HOST_ONLY_KEYS_BY_SECTION, + REPO_HOST_ONLY_KEYS_BY_SECTION, + REPO_OVERRIDABLE_KEYS_BY_HOST_SECTION, +) class CliArgs: @staticmethod def _host_only_setting_arg(arg: str) -> str | None: """Return a protected setting token when a CLI arg targets a host-only key.""" - setting_name = arg.removeprefix('--').split('=', 1)[0].replace('__', '.') + setting_name = arg.lstrip('-').split('=', 1)[0].strip().replace('__', '.') section, separator, key = setting_name.partition('.') if not separator: - if section in REPO_OVERRIDABLE_KEYS_BY_HOST_SECTION or section in REPO_HOST_ONLY_KEYS_BY_SECTION: + if (section in REPO_OVERRIDABLE_KEYS_BY_HOST_SECTION + or section in REPO_HOST_ONLY_KEYS_BY_SECTION + or section in CLI_HOST_ONLY_KEYS_BY_SECTION): return f'.{section}' return None @@ -20,6 +26,9 @@ def _host_only_setting_arg(arg: str) -> str | None: host_only_keys = REPO_HOST_ONLY_KEYS_BY_SECTION.get(section, frozenset()) if key.split('.', 1)[0] in host_only_keys: return f'.{section}.{key}' + cli_host_only_keys = CLI_HOST_ONLY_KEYS_BY_SECTION.get(section, frozenset()) + if key.split('.', 1)[0] in cli_host_only_keys: + return f'.{section}.{key}' return None @staticmethod diff --git a/pr_agent/algo/comment_identity.py b/pr_agent/algo/comment_identity.py new file mode 100644 index 0000000000..edaac5c086 --- /dev/null +++ b/pr_agent/algo/comment_identity.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import string +from enum import Enum +from typing import Iterable + +from pr_agent.config_loader import get_settings +from pr_agent.log import get_logger + + +class PRReviewHeader(str, Enum): + REGULAR = "## PR Reviewer Guide" + INCREMENTAL = "## Incremental PR Reviewer Guide" + + +class PRReviewIdentity(str, Enum): + REGULAR = "" + INCREMENTAL = "" + + +class PRCodeSuggestionsHeader(str, Enum): + SUMMARY = "## PR Code Suggestions ✨" + + +class PRCodeSuggestionsIdentity(str, Enum): + SUMMARY = "" + NO_SUGGESTIONS = "" + UNANCHORED = "" + + +class PRDescriptionHeader(str, Enum): + DIAGRAM_WALKTHROUGH = "Diagram Walkthrough" + FILE_WALKTHROUGH = "File Walkthrough" + + +_ALL_COMMENT_IDENTITIES = ( + PRReviewIdentity.REGULAR.value, + PRReviewIdentity.INCREMENTAL.value, + PRCodeSuggestionsIdentity.SUMMARY.value, + PRCodeSuggestionsIdentity.NO_SUGGESTIONS.value, + PRCodeSuggestionsIdentity.UNANCHORED.value, +) +_REVIEW_IDENTITY_HEADER_LINES = 5 +_MARKDOWN_PUNCTUATION_ESCAPE_TABLE = str.maketrans( + {character: f"\\{character}" for character in string.punctuation} +) + + +def _get_configured_heading(setting_name: str, default_heading: str) -> str: + configured_heading = get_settings().get(setting_name) + if ( + not isinstance(configured_heading, str) + or not configured_heading.strip() + or configured_heading.splitlines() != [configured_heading] + ): + get_logger().warning( + f"Invalid {setting_name}; using the default heading" + ) + configured_heading = default_heading + return configured_heading.strip() + + +def format_pr_review_header(incremental: bool = False) -> str: + """Return the visible review heading while keeping identity out of presentation.""" + default_heading = PRReviewHeader.REGULAR.value.removeprefix("## ") + heading = _get_configured_heading("pr_reviewer.review_heading", default_heading) + incremental_prefix = "Incremental " if incremental else "" + return f"## {incremental_prefix}{heading} 🔍" + + +def format_pr_code_suggestions_header(markdown_level: int = 2) -> str: + """Return the visible suggestions heading while keeping identity out of presentation.""" + default_heading = ( + PRCodeSuggestionsHeader.SUMMARY.value + .removeprefix("## ") + .removesuffix(" ✨") + ) + heading = _get_configured_heading( + "pr_code_suggestions.suggestions_heading", + default_heading, + ) + markdown_prefix = "#" * markdown_level + return f"{markdown_prefix} {heading} ✨" + + +def format_pr_questions_header(*, escape_markdown: bool = True) -> str: + """Return the visible heading for top-level /ask answers.""" + heading = _get_configured_heading("pr_questions.ask_heading", "Ask") + if escape_markdown: + heading = heading.translate(_MARKDOWN_PUNCTUATION_ESCAPE_TABLE) + return f"### **{heading}** ❓" + + +def hidden_marker_forms(identity: str) -> tuple[str, ...]: + """Return both stored forms of a known comment identity.""" + for marker in _ALL_COMMENT_IDENTITIES: + reference = f"[{marker[5:-4]}]: https://github.com/The-PR-Agent/pr-agent" + if identity in (marker, reference): + return marker, reference + return (identity,) + + +def render_hidden_marker(identity: str, git_provider=None) -> str: + """Use a link reference on providers that escape HTML comments.""" + forms = hidden_marker_forms(identity) + supports_html = getattr(git_provider, "supports_html_comment_markers", lambda: True) + return forms[-1] if supports_html() is False else forms[0] + + +def comment_matches_identity(body: str, identity: str) -> bool: + """Match hidden markers only as exact lines near the top; legacy headers as prefixes.""" + if not isinstance(body, str) or not isinstance(identity, str) or not identity: + return False + forms = hidden_marker_forms(identity) + if identity.startswith("") -CODE_MARKER_RE = re.compile(r"") -KEY_ISSUE_LOCATION_MARKER_RE = re.compile(r"") +_MARKER_URL = r"https://github.com/The-PR-Agent/pr-agent" +BODY_MARKER_RE = re.compile( + rf"|\[pr-agent-dedup: " + rf"([a-f0-9]{{12}})\]: {_MARKER_URL}" +) +CODE_MARKER_RE = re.compile( + rf"|\[pr-agent-dedup-code: " + rf"([a-f0-9]{{12}})\]: {_MARKER_URL}" +) +KEY_ISSUE_LOCATION_MARKER_RE = re.compile( + rf"|" + rf"\[pr-agent-key-issue-location: ([a-f0-9]{{12}})\]: {_MARKER_URL}" +) _MARKER_RES = (BODY_MARKER_RE, CODE_MARKER_RE, KEY_ISSUE_LOCATION_MARKER_RE) _LEAD_RE = re.compile(r"^\*\*Suggestion:\*\*\s*", re.IGNORECASE) _TAG_RE = re.compile(r"\[[^\]]+?,\s*importance:\s*\d+\]", re.IGNORECASE) _WS_RE = re.compile(r"\s+") _CODE_BLOCK_RE = re.compile(r"```suggestion[^\n]*\n(.*?)```", re.DOTALL) +_DIFF_BLOCK_RE = re.compile(r"```diff[^\n]*\n(.*?)```", re.DOTALL) def has_marker(body: str) -> bool: @@ -63,7 +74,7 @@ def marker_fingerprints(body: str) -> set: found = set() for marker_re in _MARKER_RES: for match in marker_re.finditer(body or ""): - found.add(match.group(1)) + found.update(group for group in match.groups() if group) return found @@ -72,6 +83,7 @@ def _strip_markers(body: str) -> str: same as its original (markers are appended after marking).""" body = BODY_MARKER_RE.sub("", body or "") body = CODE_MARKER_RE.sub("", body) + body = KEY_ISSUE_LOCATION_MARKER_RE.sub("", body) return body @@ -104,12 +116,11 @@ def key_issue_location_fingerprint(fingerprint: str, start_line: int, end_line: def code_fingerprint(relevant_file: str, target_line_no, body: str) -> Optional[str]: - match = _CODE_BLOCK_RE.search(_strip_markers(body)) - if not match: + code = extract_suggestion_code(body) + if not code: return None # Do not lower-case: code is case-sensitive, so case-only differences # must produce distinct fingerprints. - code = match.group(1) code = _WS_RE.sub(" ", code).strip() if not code: return None @@ -118,16 +129,44 @@ def code_fingerprint(relevant_file: str, target_line_no, body: str) -> Optional[ def extract_suggestion_code(body: str) -> Optional[str]: - match = _CODE_BLOCK_RE.search(_strip_markers(body)) - if not match: + body = _strip_markers(body) + match = _CODE_BLOCK_RE.search(body) + if match: + return match.group(1).strip("\n") + diff_match = _DIFF_BLOCK_RE.search(body) + if diff_match: + return _reconstruct_improved_code(diff_match.group(1)) + return None + + +def _reconstruct_improved_code(diff_text: str) -> Optional[str]: + """Rebuild the post-change code from a unified diff inside a ```diff fence. + + Context and added lines drop their leading space or ``+`` prefix and removed + lines are dropped, mirroring how Azure DevOps renders a suggestion as a diff + block.""" + lines = [] + for line in diff_text.splitlines(): + if line.startswith((" ", "+")): + lines.append(line[1:]) + elif line.startswith("-"): + continue + if not lines: return None - return match.group(1).strip("\n") + return "\n".join(lines).strip("\n") + +def _render_marker(prefix: str, fingerprint: str, git_provider=None) -> str: + supports_html = getattr(git_provider, "supports_html_comment_markers", lambda: True) + if supports_html() is False: + return f"[{prefix}: {fingerprint}]: https://github.com/The-PR-Agent/pr-agent" + return f"" -def build_markers(body_fp: str, code_fp: Optional[str]) -> str: - markers = [f""] + +def build_markers(body_fp: str, code_fp: Optional[str], git_provider=None) -> str: + markers = [_render_marker("pr-agent-dedup", body_fp, git_provider)] if code_fp is not None: - markers.append(f"") + markers.append(_render_marker("pr-agent-dedup-code", code_fp, git_provider)) return "\n".join(markers) @@ -139,17 +178,17 @@ def _append_markers(body: str, markers: str, max_chars: Optional[int]) -> str: def body_with_markers(body: str, body_fp: str, code_fp: "Optional[str]", - max_chars: "Optional[int]" = None) -> str: + max_chars: "Optional[int]" = None, git_provider=None) -> str: """Append the dedup marker(s) to a comment body. If max_chars is given and body + markers would exceed it, the body is clipped (never the markers) so the fingerprint marker always survives for the next run's scan.""" - return _append_markers(body, build_markers(body_fp, code_fp), max_chars) + return _append_markers(body, build_markers(body_fp, code_fp, git_provider), max_chars) def key_issue_body_with_markers(body: str, body_fp: str, location_fp: str, - max_chars: Optional[int] = None) -> str: - markers = (f"{build_markers(body_fp, None)}\n" - f"") + max_chars: Optional[int] = None, git_provider=None) -> str: + markers = (f"{build_markers(body_fp, None, git_provider)}\n" + f"{_render_marker('pr-agent-key-issue-location', location_fp, git_provider)}") return _append_markers(body, markers, max_chars) @@ -164,10 +203,16 @@ def inline_comment_line(comment: dict): def iter_existing_inline_comment_bodies(git_provider) -> Iterator[str]: """Yield the body of every existing comment on the current PR/MR. - Dispatch is by provider class name so this module needs no provider - import. Unsupported providers raise NotImplementedError, which the store - treats as "cannot dedup here" and degrades to within-run dedup only. + Providers with a persistent comment-body capability are handled through + that interface. The remaining legacy providers use their provider-specific + APIs; unsupported providers raise NotImplementedError so the store can + degrade to within-run dedup only. """ + persistent_bodies = getattr(git_provider, "get_persistent_comment_bodies", None) + if callable(persistent_bodies): + yield from persistent_bodies() + return + provider_name = type(git_provider).__name__ if provider_name == "GithubProvider": for comment in git_provider.pr.get_comments(): diff --git a/pr_agent/algo/language_handler.py b/pr_agent/algo/language_handler.py index dee3b8a481..9d2e0ec9e3 100644 --- a/pr_agent/algo/language_handler.py +++ b/pr_agent/algo/language_handler.py @@ -77,10 +77,19 @@ def get_language(filename: str) -> str | None: return get_language +def numeric_languages(languages: Dict) -> Dict: + """ + Keep only the {language: size} entries of a provider's get_languages() result. PyGithub 2.x + adds a "url" string to every GET dict it returns, and a str cannot be ranked against sizes. + """ + return {k: v for k, v in (languages or {}).items() if isinstance(v, (int, float))} + + def sort_files_by_main_languages(languages: Dict, files: list): """ Sort files by their main language, put the files that are in the main language first and the rest files after """ + languages = numeric_languages(languages) # sort languages by their size languages_sorted_list = [k for k, v in sorted(languages.items(), key=lambda item: item[1], reverse=True)] # languages_sorted = sorted(languages, key=lambda x: x[1], reverse=True) diff --git a/pr_agent/algo/output_models.py b/pr_agent/algo/output_models.py new file mode 100644 index 0000000000..4e28b50c63 --- /dev/null +++ b/pr_agent/algo/output_models.py @@ -0,0 +1,238 @@ +"""Pydantic models for structured outputs described by prompt templates.""" + +from enum import Enum +from typing import List, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, field_validator + + +class SubPR(BaseModel): + model_config = ConfigDict(extra="forbid") + + relevant_files: List[str] + title: str + + +class KeyIssuesComponentLink(BaseModel): + model_config = ConfigDict(extra="forbid") + + relevant_file: str + issue_header: str + issue_content: str + start_line: int + end_line: int + + +class TodoSection(BaseModel): + model_config = ConfigDict(extra="forbid") + + relevant_file: str + line_number: int + content: str + + +class TicketCompliance(BaseModel): + model_config = ConfigDict(extra="forbid") + + ticket_url: str + ticket_requirements: str + fully_compliant_requirements: str + not_compliant_requirements: str + requires_further_human_verification: str + + +class ContributionTimeCostEstimate(BaseModel): + model_config = ConfigDict(extra="forbid") + + best_case: str + average_case: str + worst_case: str + + +class Review(BaseModel): + model_config = ConfigDict(extra="forbid") + + ticket_compliance_check: Optional[List[TicketCompliance]] = None + estimated_effort_to_review: Optional[StrictInt] = Field( + default=None, alias="estimated_effort_to_review_[1-5]", ge=1, le=5 + ) + risk_level: Optional[Literal["low", "medium", "high"]] = None + merge_recommendation: Optional[Literal["safe_to_merge", "merge_with_caution", "changes_required"]] = None + review_priority_files: Optional[List[str]] = None + contribution_time_cost_estimate: Optional[ContributionTimeCostEstimate] = None + score: Optional[StrictInt] = Field(default=None, ge=0, le=100) + relevant_tests: Optional[Literal["Yes", "No"]] = None + insights_from_user_answers: Optional[str] = None + key_issues_to_review: List[KeyIssuesComponentLink] + security_concerns: Optional[str] = None + todo_sections: Optional[Union[List[TodoSection], str]] = None + can_be_split: Optional[List[SubPR]] = Field(default=None, max_length=3) + + @field_validator("risk_level", "merge_recommendation", "relevant_tests", mode="before") + @classmethod + def _strip_literal_values(cls, value): + return value.strip() if isinstance(value, str) else value + + +class PRReview(BaseModel): + model_config = ConfigDict(extra="forbid") + + review: Review + + +class CodeSuggestion(BaseModel): + relevant_file: str + language: str + existing_code: str + suggestion_content: str + improved_code: str + one_sentence_summary: str + label: str + + +class PRCodeSuggestions(BaseModel): + code_suggestions: List[CodeSuggestion] + + +class CodeSuggestionFeedback(BaseModel): + suggestion_summary: str + relevant_file: str + relevant_lines_start: int + relevant_lines_end: int + suggestion_score: int = Field(ge=0, le=10) + why: str + + +class PRCodeSuggestionsFeedback(BaseModel): + code_suggestions: List[CodeSuggestionFeedback] + + +class PRType(str, Enum): + BUG_FIX = "Bug fix" + TESTS = "Tests" + ENHANCEMENT = "Enhancement" + DOCUMENTATION = "Documentation" + OTHER = "Other" + + +class FileDescription(BaseModel): + filename: str + changes_summary: Optional[str] = None + changes_title: str + label: str + + +class PRDescription(BaseModel): + type: List[PRType] = Field(min_length=1) + description: Optional[str] = None + title: str + changes_diagram: Optional[str] = None + pr_files: Optional[List[FileDescription]] = Field(default=None, max_length=20) + + +class PRDescriptionHeaders(BaseModel): + type: List[PRType] = Field(min_length=1) + description: Optional[str] = None + title: str + changes_diagram: Optional[str] = None + + +class PRFilesWalkthrough(BaseModel): + pr_files: List[FileDescription] + + +class Label(str, Enum): + BUG_FIX = "Bug fix" + TESTS = "Tests" + ENHANCEMENT = "Enhancement" + DOCUMENTATION = "Documentation" + OTHER = "Other" + + +class Labels(BaseModel): + labels: List[str] + + @field_validator("labels", mode="before") + @classmethod + def _normalize_labels(cls, value): + if isinstance(value, str): + entries = value.split(",") + elif isinstance(value, list): + if not value: + return [] + entries = value + else: + raise ValueError("labels must be a list or comma-separated string") + + labels = [] + for entry in entries: + if isinstance(entry, dict): + entry = next( + ( + entry[key] + for key in ("name", "label", "title", "value") + if isinstance(entry.get(key), str) and entry[key].strip() + ), + None, + ) + if isinstance(entry, bool) or not isinstance(entry, (str, int, float)): + continue + label = str(entry).strip() + if label: + labels.append(label) + + if not labels: + raise ValueError("labels must contain at least one usable value") + return labels + + +class CodeDocumentationItem(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + relevant_file: str = Field(alias="relevant file") + relevant_line: StrictInt = Field(alias="relevant line", ge=1) + doc_placement: Literal["before", "after"] = Field(alias="doc placement") + documentation: str + + +class CodeDocumentation(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + code_documentation: List[CodeDocumentationItem] = Field(alias="Code Documentation") + + @field_validator("code_documentation", mode="after") + @classmethod + def _reject_duplicate_items(cls, items): + serialized = [tuple(sorted(item.model_dump().items())) for item in items] + if len(serialized) != len(set(serialized)): + raise ValueError("Code Documentation items must be unique") + return items + + +class PRRankResponses(BaseModel): + which_response_was_better: Literal[0, 1, 2] + why: str + score_response1: int = Field(ge=1, le=10) + score_response2: int = Field(ge=1, le=10) + + +class RelevantSection(BaseModel): + file_name: str + relevant_section_header_string: str + + +class DocHelper(BaseModel): + user_question: str + response: str + relevant_sections: List[RelevantSection] + question_is_relevant: Optional[int] = Field(default=None, ge=0, le=1) + + +class FileIdxAndPath(BaseModel): + idx: int = Field(ge=0) + file_name: str + + +class DocHeadingsHelper(BaseModel): + user_question: str + relevant_files_ranking: List[FileIdxAndPath] diff --git a/pr_agent/algo/pr_processing.py b/pr_agent/algo/pr_processing.py index cad8513e90..a269d0c9c3 100644 --- a/pr_agent/algo/pr_processing.py +++ b/pr_agent/algo/pr_processing.py @@ -1,6 +1,8 @@ from __future__ import annotations import traceback +from contextvars import ContextVar +from dataclasses import dataclass, replace from typing import Callable, List, Tuple from pr_agent.algo.git_patch_processing import ( @@ -11,9 +13,10 @@ from pr_agent.algo.language_handler import sort_files_by_main_languages from pr_agent.algo.model_routing import route_primary_model from pr_agent.algo.run_details import record_model_used +from pr_agent.algo.token_budget import AttemptTokenBudget, clip_tokens from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.types import EDIT_TYPE -from pr_agent.algo.utils import ModelType, clip_tokens, get_max_tokens, get_model +from pr_agent.algo.utils import ModelType, get_model from pr_agent.config_loader import get_settings, get_verbosity_level from pr_agent.git_providers.git_provider import GitProvider from pr_agent.log import get_logger @@ -28,6 +31,93 @@ OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD = 1000 MAX_EXTRA_LINES = 10 +_effective_fallback_chain: ContextVar[tuple[tuple[str, str | None], ...] | None] = ContextVar( + "pr_agent_effective_fallback_chain", default=None +) + + +def get_effective_fallback_chain() -> tuple[tuple[str, str | None], ...] | None: + """Return the model/deployment chain selected for the current retry invocation.""" + return _effective_fallback_chain.get() + + +def _count_raw_and_stripped_tokens(token_handler: TokenHandler, text: str) -> int: + """Count both raw and stripped forms because either representation may be rendered.""" + token_count = token_handler.count_tokens(text) + stripped = text.strip() + if stripped != text: + token_count = max(token_count, token_handler.count_tokens(stripped)) + return token_count + + +def _append_metadata_section( + final_diff: str, + curr_token: int, + section: str, + max_tokens: int, + token_handler: TokenHandler, +) -> tuple[str, int, str]: + """Append one clipped metadata section when its rendered form fits the input budget.""" + if not section: + return final_diff, curr_token, section + + separator = "\n\n" + available_tokens = max_tokens - curr_token + separator_tokens = token_handler.count_tokens(separator) + section_budget = available_tokens - separator_tokens + if section_budget <= 0: + return final_diff, curr_token, "" + + section_tokens = token_handler.count_tokens(section) + clipped_section = clip_tokens(section, section_budget, num_input_tokens=section_tokens) + if not clipped_section: + return final_diff, curr_token, "" + + candidate = final_diff + separator + clipped_section + candidate_tokens = token_handler.prompt_tokens + _count_raw_and_stripped_tokens(token_handler, candidate) + if candidate_tokens <= max_tokens: + return candidate, candidate_tokens, clipped_section + + return final_diff, curr_token, "" + + +def _find_verified_fitting_prefix_length(items, max_length: int, fits: Callable[[list], bool]) -> int: + """Find a fitting ordered prefix with logarithmic exact-count probes. + + Token counts are not assumed to be monotone across concatenated strings. The returned prefix + is always one that ``fits`` verified directly, although a longer fitting prefix may exist. + """ + low = 0 + high = max_length + while low < high: + middle = (low + high + 1) // 2 + if fits(items[:middle]): + low = middle + else: + high = middle - 1 + + if low == 0 and max_length > 0 and fits(items[:1]): + return 1 + return low + + +@dataclass +class PreparedPRDiff: + """The single-call diff and compressed file data prepared for one model attempt. + + The compressed file data is request-scoped. It is only reused by a caller that keeps the + same source or bound token handler and model. A different model rebuilds its own counts. + """ + + diff: str + remaining_files_list: list + file_dict: dict | None = None + files_by_name: dict | None = None + model: str | None = None + add_line_numbers_to_hunks: bool = False + token_handler: TokenHandler | None = None + attempt_budget: AttemptTokenBudget | None = None + def cap_and_log_extra_lines(value, direction) -> int: try: @@ -47,7 +137,19 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, add_line_numbers_to_hunks: bool = False, disable_extra_lines: bool = False, large_pr_handling=False, - return_remaining_files=False): + return_remaining_files=False, + return_prepared=False, + output_token_reserve: Callable[[str, int], int] | None = None): + budget = AttemptTokenBudget.for_attempt( + model, token_handler, output_token_reserve=output_token_reserve + ) + token_handler = budget.token_handler + soft_token_budget = budget.available_tokens( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, preserve_minimum=True, clamp=False + ) + hard_token_budget = budget.available_tokens( + OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, preserve_minimum=True, clamp=False + ) if disable_extra_lines: PATCH_EXTRA_LINES_BEFORE = 0 PATCH_EXTRA_LINES_AFTER = 0 @@ -64,7 +166,7 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, if pr_languages: try: get_logger().info(f"PR main language: {pr_languages[0]['language']}") - except Exception as e: + except Exception: pass # generate a standard diff string, with patch extension @@ -73,16 +175,28 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, patch_extra_lines_before=PATCH_EXTRA_LINES_BEFORE, patch_extra_lines_after=PATCH_EXTRA_LINES_AFTER) # if we are under the limit, return the full diff - if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < get_max_tokens(model): - get_logger().info(f"Tokens: {total_tokens}, total tokens under limit: {get_max_tokens(model)}, " + if total_tokens - token_handler.prompt_tokens < soft_token_budget: + get_logger().info(f"Tokens: {total_tokens}, total tokens under limit: {budget.context_window}, " f"returning full diff.") - return "\n".join(patches_extended) + full_diff = "\n".join(patches_extended) + if return_prepared: + return PreparedPRDiff(full_diff, [], model=model, + add_line_numbers_to_hunks=add_line_numbers_to_hunks, + token_handler=token_handler, attempt_budget=budget) + return full_diff # if we are over the limit, start pruning (If we got here, we will not extend the patches with extra lines) - get_logger().info(f"Tokens: {total_tokens}, total tokens over limit: {get_max_tokens(model)}, " + get_logger().info(f"Tokens: {total_tokens}, total tokens over limit: {budget.context_window}, " f"pruning diff.") patches_compressed_list, total_tokens_list, deleted_files_list, remaining_files_list, file_dict, files_in_patches_list = \ - pr_generate_compressed_diff(pr_languages, token_handler, model, add_line_numbers_to_hunks, large_pr_handling) + pr_generate_compressed_diff( + pr_languages, + token_handler, + soft_token_budget, + hard_token_budget, + add_line_numbers_to_hunks, + large_pr_handling, + ) if large_pr_handling and len(patches_compressed_list) > 1: get_logger().info(f"Large PR handling mode, and found {len(patches_compressed_list)} patches with original diff.") @@ -90,13 +204,12 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, # return the first patch patches_compressed = patches_compressed_list[0] - total_tokens_new = total_tokens_list[0] files_in_patch = files_in_patches_list[0] # Insert additional information about added, modified, and deleted files if there is enough space - max_tokens = get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD - curr_token = total_tokens_new # == token_handler.count_tokens(final_diff)+token_handler.prompt_tokens + max_tokens = token_handler.prompt_tokens + hard_token_budget final_diff = "\n".join(patches_compressed) + curr_token = token_handler.prompt_tokens + token_handler.count_tokens(final_diff) delta_tokens = 10 added_list_str = modified_list_str = deleted_list_str = "" unprocessed_files = [] @@ -125,20 +238,34 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, deleted_list_str = deleted_list_str + f"\n{filename}" # prune the added, modified, and deleted files lists, and add them to the final diff - added_list_str = clip_tokens(added_list_str, max_tokens - curr_token) - if added_list_str: - final_diff = final_diff + "\n\n" + added_list_str - curr_token += token_handler.count_tokens(added_list_str) + 2 - modified_list_str = clip_tokens(modified_list_str, max_tokens - curr_token) - if modified_list_str: - final_diff = final_diff + "\n\n" + modified_list_str - curr_token += token_handler.count_tokens(modified_list_str) + 2 - deleted_list_str = clip_tokens(deleted_list_str, max_tokens - curr_token) - if deleted_list_str: - final_diff = final_diff + "\n\n" + deleted_list_str + final_diff, curr_token, added_list_str = _append_metadata_section( + final_diff, curr_token, added_list_str, max_tokens, token_handler + ) + final_diff, curr_token, modified_list_str = _append_metadata_section( + final_diff, curr_token, modified_list_str, max_tokens, token_handler + ) + final_diff, curr_token, deleted_list_str = _append_metadata_section( + final_diff, curr_token, deleted_list_str, max_tokens, token_handler + ) get_logger().debug(f"After pruning, added_list_str: {added_list_str}, modified_list_str: {modified_list_str}, " f"deleted_list_str: {deleted_list_str}") + if return_prepared: + files_by_name = { + file.filename: file + for language in pr_languages + for file in language["files"] + } + return PreparedPRDiff( + final_diff, + remaining_files_list, + file_dict, + files_by_name, + model=model, + add_line_numbers_to_hunks=add_line_numbers_to_hunks, + token_handler=token_handler, + attempt_budget=budget, + ) if not return_remaining_files: return final_diff else: @@ -146,7 +273,12 @@ def get_pr_diff(git_provider: GitProvider, token_handler: TokenHandler, def get_pr_diff_multiple_patchs(git_provider: GitProvider, token_handler: TokenHandler, model: str, - add_line_numbers_to_hunks: bool = False, disable_extra_lines: bool = False): + add_line_numbers_to_hunks: bool = False, disable_extra_lines: bool = False, + output_token_reserve: Callable[[str, int], int] | None = None): + budget = AttemptTokenBudget.for_attempt( + model, token_handler, output_token_reserve=output_token_reserve + ) + token_handler = budget.token_handler diff_files = git_provider.get_diff_files() # get pr languages @@ -154,15 +286,178 @@ def get_pr_diff_multiple_patchs(git_provider: GitProvider, token_handler: TokenH if pr_languages: try: get_logger().info(f"PR main language: {pr_languages[0]['language']}") - except Exception as e: + except Exception: pass + soft_token_budget = budget.available_tokens( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, preserve_minimum=True, clamp=False + ) + hard_token_budget = budget.available_tokens( + OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, preserve_minimum=True, clamp=False + ) patches_compressed_list, total_tokens_list, deleted_files_list, remaining_files_list, file_dict, files_in_patches_list = \ - pr_generate_compressed_diff(pr_languages, token_handler, model, add_line_numbers_to_hunks, large_pr_handling=True) + pr_generate_compressed_diff( + pr_languages, + token_handler, + soft_token_budget, + hard_token_budget, + add_line_numbers_to_hunks, + large_pr_handling=True, + ) return patches_compressed_list, total_tokens_list, deleted_files_list, remaining_files_list, file_dict, files_in_patches_list +def _pack_pr_multi_diffs(file_dict: dict, + token_handler: TokenHandler, + max_calls: int, + return_remaining_files: bool, + token_budget: int): + """Pack diffs additively, then verify each rendered group against the input budget.""" + final_diff_list = [] + files_in_patches = set() + + def count_chunk(candidate_patches): + rendered = "\n".join(candidate_patches) + return _count_raw_and_stripped_tokens(token_handler, rendered) + + def clip_single_patch(filename, patch): + if get_settings().config.get("large_patch_policy", "skip") != "clip": + get_logger().warning(f"Patch too large, skipping: {filename}") + return None + patch_tokens = count_chunk([patch]) + patch_clipped = clip_tokens( + patch, + token_budget, + delete_last_line=True, + num_input_tokens=patch_tokens, + ) + clipped_tokens = count_chunk([patch_clipped]) if patch_clipped else token_budget + 1 + if clipped_tokens > token_budget: + get_logger().warning(f"Patch too large, skipping: {filename}") + return None + get_logger().info(f"Clipped large patch for file: {filename}") + return filename, patch_clipped, clipped_tokens + + packable = [] + for filename, data in file_dict.items(): + patch = data["patch"] + new_patch_tokens = data["tokens"] + if not patch: + continue + + if new_patch_tokens > token_budget: + clipped_item = clip_single_patch(filename, patch) + if not clipped_item: + continue + filename, patch, new_patch_tokens = clipped_item + + packable.append((filename, patch, new_patch_tokens)) + + separator_tokens = token_handler.count_tokens("\n") + additive_groups = [] + current_group = [] + current_tokens = 0 + for item in packable: + item_tokens = item[2] + item_cost = item_tokens + (separator_tokens if current_group else 0) + if current_group and current_tokens + item_cost > token_budget: + additive_groups.append(current_group) + current_group = [] + current_tokens = 0 + item_cost = item_tokens + current_group.append(item) + current_tokens += item_cost + if current_group: + additive_groups.append(current_group) + + packed_groups = [] + + def append_group(group): + if not group or len(packed_groups) >= max_calls: + return False + packed_groups.append(group) + files_in_patches.update(filename for filename, _, _ in group) + return True + + for group in additive_groups: + if len(packed_groups) >= max_calls: + break + + pending_group = group + while pending_group and len(packed_groups) < max_calls: + pending_patches = [patch for _, patch, _ in pending_group] + if count_chunk(pending_patches) <= token_budget: + append_group(pending_group) + break + + prefix_length = _find_verified_fitting_prefix_length( + pending_group, + len(pending_group) - 1, + lambda prefix: count_chunk([patch for _, patch, _ in prefix]) <= token_budget, + ) + if prefix_length: + append_group(pending_group[:prefix_length]) + pending_group = pending_group[prefix_length:] + continue + + item = pending_group[0] + clipped_item = clip_single_patch(item[0], item[1]) + if clipped_item: + append_group([clipped_item]) + pending_group = pending_group[1:] + + packed_all = len(files_in_patches) == len(packable) + for index, group in enumerate(packed_groups): + rendered_chunk = "\n".join(patch for _, patch, _ in group) + if packed_all and index == len(packed_groups) - 1: + rendered_chunk = rendered_chunk.strip() + final_diff_list.append(rendered_chunk) + + if len(files_in_patches) < len(packable) and get_verbosity_level() >= 2: + get_logger().info(f"Reached max calls ({max_calls})") + + if not return_remaining_files: + return final_diff_list + + remaining_files_list = [ + filename for filename in file_dict + if filename not in files_in_patches + ] + return final_diff_list, remaining_files_list + + +def _get_pr_multi_diffs_from_prepared(prepared_diff: PreparedPRDiff, + token_handler: TokenHandler, + max_calls: int, + return_remaining_files: bool, + token_budget: int): + """Pack already transformed file patches without repeating preparation work. + + ``get_pr_diff`` and the review chunking path use the same model-specific token handler and + line-number format. Reusing its compressed file dictionary preserves the existing packing + and large-patch policy while avoiding a second provider fetch, patch conversion, and token + count for every file. + """ + file_dict = {} + for filename, data in (prepared_diff.file_dict or {}).items(): + patch = data["patch"] + tokens = data["tokens"] + file = (prepared_diff.files_by_name or {}).get(filename) + if file and file.ai_file_summary and get_settings().get("config.enable_ai_metadata", False): + patch = add_ai_summary_top_patch(file, patch) + tokens = token_handler.count_tokens(patch) + file_dict[filename] = {**data, "patch": patch, "tokens": tokens} + + return _pack_pr_multi_diffs( + file_dict, + token_handler, + max_calls, + return_remaining_files, + token_budget, + ) + + def pr_generate_extended_diff(pr_languages: list, token_handler: TokenHandler, add_line_numbers_to_hunks: bool, @@ -199,16 +494,20 @@ def pr_generate_extended_diff(pr_languages: list, patch_tokens = token_handler.count_tokens(full_extended_patch) file.tokens = patch_tokens - total_tokens += patch_tokens patches_extended_tokens.append(patch_tokens) patches_extended.append(full_extended_patch) + if patches_extended: + total_tokens += _count_raw_and_stripped_tokens(token_handler, "\n".join(patches_extended)) return patches_extended, total_tokens, patches_extended_tokens -def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, model: str, +def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, + soft_token_budget: int, hard_token_budget: int, convert_hunks_to_line_numbers: bool, - large_pr_handling: bool) -> Tuple[list, list, list, list, dict, list]: + large_pr_handling: bool, + ) -> Tuple[list, list, list, list, dict, list]: + """Prepare patches using the caller's soft and hard diff-only capacities.""" deleted_files_list = [] for lang in top_langs: @@ -248,15 +547,14 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo new_patch_tokens = token_handler.count_tokens(patch) file_dict[file.filename] = {'patch': patch, 'tokens': new_patch_tokens, 'edit_type': file.edit_type} - max_tokens_model = get_max_tokens(model) - # first iteration files_in_patches_list = [] remaining_files_list = [file.filename for file in sorted_files] patches_list =[] total_tokens_list = [] total_tokens, patches, remaining_files_list, files_in_patch_list = generate_full_patch(convert_hunks_to_line_numbers, file_dict, - max_tokens_model, remaining_files_list, token_handler) + soft_token_budget, remaining_files_list, token_handler, + hard_token_budget=hard_token_budget) patches_list.append(patches) total_tokens_list.append(total_tokens) files_in_patches_list.append(files_in_patch_list) @@ -264,12 +562,13 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo # additional iterations (if needed) if large_pr_handling: NUMBER_OF_ALLOWED_ITERATIONS = get_settings().pr_description.get("max_ai_calls", 4) - 1 # one more call is to summarize - for i in range(NUMBER_OF_ALLOWED_ITERATIONS-1): + for _ in range(NUMBER_OF_ALLOWED_ITERATIONS-1): if remaining_files_list: total_tokens, patches, remaining_files_list, files_in_patch_list = generate_full_patch(convert_hunks_to_line_numbers, file_dict, - max_tokens_model, - remaining_files_list, token_handler) + soft_token_budget, + remaining_files_list, token_handler, + hard_token_budget=hard_token_budget) if patches: patches_list.append(patches) total_tokens_list.append(total_tokens) @@ -280,20 +579,21 @@ def pr_generate_compressed_diff(top_langs: list, token_handler: TokenHandler, mo return patches_list, total_tokens_list, deleted_files_list, remaining_files_list, file_dict, files_in_patches_list -def generate_full_patch(convert_hunks_to_line_numbers, file_dict, max_tokens_model,remaining_files_list_prev, token_handler): +def generate_full_patch(convert_hunks_to_line_numbers, file_dict, soft_token_budget, remaining_files_list_prev, + token_handler, *, hard_token_budget: int): + """Admit rendered patches using diff-only budgets; return prompt-inclusive totals.""" total_tokens = token_handler.prompt_tokens # initial tokens patches = [] remaining_files_list_new = [] files_in_patch_list = [] + separator_tokens = None + remaining_files = set(remaining_files_list_prev) for filename, data in file_dict.items(): - if filename not in remaining_files_list_prev: + if filename not in remaining_files: continue patch = data['patch'] - edit_type = data['edit_type'] - - # Hard Stop, no more tokens - if total_tokens > max_tokens_model - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD: + if total_tokens - token_handler.prompt_tokens > hard_token_budget: get_logger().warning(f"File was fully skipped, no more tokens: {filename}.") remaining_files_list_new.append(filename) continue @@ -304,26 +604,45 @@ def generate_full_patch(convert_hunks_to_line_numbers, file_dict, max_tokens_mod else: patch_final = "\n\n" + patch.strip() new_patch_tokens = token_handler.count_tokens(patch_final) - else: - patch_final = "" - new_patch_tokens = 0 - - # If the patch is too large, just show the file name - if total_tokens + new_patch_tokens > max_tokens_model - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: - # Current logic is to skip the patch if it's too large - # TODO: Option for alternative logic to remove hunks from the patch to reduce the number of tokens - # until we meet the requirements - if get_verbosity_level() >= 2: - get_logger().warning(f"Patch too large, skipping it: '{filename}'") - remaining_files_list_new.append(filename) - continue - - if patch: + if patches and separator_tokens is None: + separator_tokens = token_handler.count_tokens("\n") + rendered_patch_tokens = new_patch_tokens + (separator_tokens or 0) + if total_tokens + rendered_patch_tokens > token_handler.prompt_tokens + soft_token_budget: + if get_verbosity_level() >= 2: + get_logger().warning(f"Patch too large, skipping it: '{filename}'") + remaining_files_list_new.append(filename) + continue patches.append(patch_final) - total_tokens += new_patch_tokens files_in_patch_list.append(filename) + total_tokens += rendered_patch_tokens if get_verbosity_level() >= 2: get_logger().info(f"Tokens: {total_tokens}, last filename: {filename}") + + def count_patches(candidate_patches): + return _count_raw_and_stripped_tokens(token_handler, "\n".join(candidate_patches)) + + if patches: + exact_total = token_handler.prompt_tokens + count_patches(patches) + if exact_total - token_handler.prompt_tokens > soft_token_budget: + best_count = _find_verified_fitting_prefix_length( + patches, + len(patches) - 1, + lambda prefix: count_patches(prefix) <= soft_token_budget, + ) + + for filename in files_in_patch_list[best_count:]: + if filename not in remaining_files_list_new: + remaining_files_list_new.append(filename) + patches = patches[:best_count] + files_in_patch_list = files_in_patch_list[:best_count] + total_tokens = ( + token_handler.prompt_tokens + count_patches(patches) + if patches + else token_handler.prompt_tokens + ) + else: + total_tokens = exact_total + return total_tokens, patches, remaining_files_list_new, files_in_patch_list @@ -335,10 +654,14 @@ async def retry_with_fallback_models(f: Callable, model_type: ModelType = ModelT if routed: # A cheaper primary for a small pull request; config.fallback_models still follow it. all_models[0], all_deployments[0] = routed + # Ignore surplus deployment entries when fewer fallback models are configured; this matches + # the existing retry loop, which stops as soon as the first successful model returns. + effective_chain = tuple(zip(all_models, all_deployments[:len(all_models)], strict=True)) original_deployment_id = get_settings().get("openai.deployment_id", None) + context_token = _effective_fallback_chain.set(effective_chain) try: # try each (model, deployment_id) pair until one is successful, otherwise raise exception - for i, (model, deployment_id) in enumerate(zip(all_models, all_deployments, strict=True)): + for i, (model, deployment_id) in enumerate(effective_chain): try: get_logger().debug( f"Generating prediction with {model}" @@ -357,6 +680,7 @@ async def retry_with_fallback_models(f: Callable, model_type: ModelType = ModelT record_model_used(model, is_fallback=i > 0) return result finally: + _effective_fallback_chain.reset(context_token) get_settings().set("openai.deployment_id", original_deployment_id) @@ -371,7 +695,8 @@ def _get_all_models(model_type: ModelType = ModelType.REGULAR) -> List[str]: model = get_settings().config.model fallback_models = get_settings().config.fallback_models if not isinstance(fallback_models, list): - fallback_models = [m.strip() for m in fallback_models.split(",")] + fallback_models = fallback_models.split(",") + fallback_models = [m.strip() for m in fallback_models if isinstance(m, str) and m.strip()] all_models = [model] + fallback_models return all_models @@ -396,7 +721,9 @@ def get_pr_multi_diffs(git_provider: GitProvider, model: str, max_calls: int = 5, add_line_numbers: bool = True, - return_remaining_files: bool = False): + return_remaining_files: bool = False, + prepared_diff: PreparedPRDiff | None = None, + output_token_reserve: Callable[[str, int], int] | None = None): """ Retrieves the diff files from a Git provider, sorts them by main language, and generates patches for each file. The patches are split into multiple groups based on the maximum number of tokens allowed for the given model. @@ -405,16 +732,51 @@ def get_pr_multi_diffs(git_provider: GitProvider, git_provider (GitProvider): An object that provides access to Git provider APIs. token_handler (TokenHandler): An object that handles tokens in the context of a pull request. model (str): The name of the model. - max_calls (int, optional): The maximum number of calls to retrieve diff files. Defaults to 5. + max_calls (int, optional): Maximum number of groups for split diffs; the full-diff fast path may still return one group. Defaults to 5. return_remaining_files (bool, optional): Also return the files the token budget left out, in the same shape as `get_pr_diff`. Files without a patch, and delete-only files, are not reported: nothing was omitted for them. Defaults to False. + prepared_diff (PreparedPRDiff, optional): Reuse compressed file data prepared by a preceding + `get_pr_diff` call for the same model attempt. Defaults to None. Returns: List[str]: A list of final diff strings, split into multiple groups based on the maximum number of tokens allowed for the given model. With `return_remaining_files`, a tuple of that list and the list of omitted file names. """ + can_reuse_prepared = ( + prepared_diff is not None + and prepared_diff.file_dict is not None + and prepared_diff.model == model + and add_line_numbers + and prepared_diff.add_line_numbers_to_hunks == add_line_numbers + and ( + prepared_diff.attempt_budget.matches(model, token_handler) + if prepared_diff.attempt_budget is not None + else prepared_diff.token_handler is token_handler + ) + ) + if can_reuse_prepared and prepared_diff.attempt_budget is not None: + # Reuse the model-bound prompt count while honoring this call's reserve policy. + budget = replace(prepared_diff.attempt_budget, output_token_reserve=output_token_reserve) + else: + budget = AttemptTokenBudget.for_attempt( + model, token_handler, output_token_reserve=output_token_reserve + ) + token_handler = budget.token_handler + soft_token_budget = budget.available_tokens( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, preserve_minimum=True, clamp=False + ) + + if can_reuse_prepared: + return _get_pr_multi_diffs_from_prepared( + prepared_diff, + token_handler, + max_calls, + return_remaining_files, + soft_token_budget, + ) + diff_files = git_provider.get_diff_files() # Sort files by main language @@ -426,7 +788,7 @@ def get_pr_multi_diffs(git_provider: GitProvider, PATCH_EXTRA_LINES_BEFORE = cap_and_log_extra_lines(PATCH_EXTRA_LINES_BEFORE, "before") PATCH_EXTRA_LINES_AFTER = cap_and_log_extra_lines(PATCH_EXTRA_LINES_AFTER, "after") - # try first a single run with standard diff string, with patch extension, and no deletions + # First try a single run with the full diff and extended patch context. patches_extended, total_tokens, patches_extended_tokens = pr_generate_extended_diff( pr_languages, token_handler, add_line_numbers_to_hunks=add_line_numbers, @@ -434,7 +796,7 @@ def get_pr_multi_diffs(git_provider: GitProvider, patch_extra_lines_after=PATCH_EXTRA_LINES_AFTER) # if we are under the limit, return the full diff - if total_tokens + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD < get_max_tokens(model): + if total_tokens - token_handler.prompt_tokens < soft_token_budget: full_diff_list = ["\n".join(patches_extended)] if patches_extended else [] return (full_diff_list, []) if return_remaining_files else full_diff_list @@ -443,17 +805,11 @@ def get_pr_multi_diffs(git_provider: GitProvider, for lang in pr_languages: sorted_files.extend(sorted(lang['files'], key=lambda x: x.tokens, reverse=True)) - patches = [] - final_diff_list = [] - files_in_patches = set() # files that made it into a chunk - total_tokens = token_handler.prompt_tokens - call_number = 1 + # Build the same transformed file dictionary used by the prepared path, preserving the + # descending token order established above. The shared packer then owns chunk boundaries, + # large-patch policy, and remaining-file tracking for both paths. + file_dict = {} for file in sorted_files: - if call_number > max_calls: - if get_verbosity_level() >= 2: - get_logger().info(f"Reached max calls ({max_calls})") - break - original_file_content_str = file.base_file new_file_content_str = file.head_file patch = file.patch @@ -475,66 +831,19 @@ def get_pr_multi_diffs(git_provider: GitProvider, if file.ai_file_summary and get_settings().get("config.enable_ai_metadata", False): patch = add_ai_summary_top_patch(file, patch) new_patch_tokens = token_handler.count_tokens(patch) - - if patch and (token_handler.prompt_tokens + new_patch_tokens) > get_max_tokens( - model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: - if get_settings().config.get('large_patch_policy', 'skip') == 'skip': - get_logger().warning(f"Patch too large, skipping: {file.filename}") - continue - elif get_settings().config.get('large_patch_policy') == 'clip': - delta_tokens = get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - token_handler.prompt_tokens - patch_clipped = clip_tokens(patch, delta_tokens, delete_last_line=True, num_input_tokens=new_patch_tokens) - new_patch_tokens = token_handler.count_tokens(patch_clipped) - if patch_clipped and (token_handler.prompt_tokens + new_patch_tokens) > get_max_tokens( - model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: - get_logger().warning(f"Patch too large, skipping: {file.filename}") - continue - else: - get_logger().info(f"Clipped large patch for file: {file.filename}") - patch = patch_clipped - else: - get_logger().warning(f"Patch too large, skipping: {file.filename}") - continue - - if patch and (total_tokens + new_patch_tokens > get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD): - final_diff = "\n".join(patches) - final_diff_list.append(final_diff) - patches = [] - total_tokens = token_handler.prompt_tokens - call_number += 1 - if call_number > max_calls: # avoid creating new patches - if get_verbosity_level() >= 2: - get_logger().info(f"Reached max calls ({max_calls})") - break - if get_verbosity_level() >= 2: - get_logger().info(f"Call number: {call_number}") - - if patch: - patches.append(patch) - files_in_patches.add(file.filename) - total_tokens += new_patch_tokens - if get_verbosity_level() >= 2: - get_logger().info(f"Tokens: {total_tokens}, last filename: {file.filename}") - - # Add the last chunk - if patches: - final_diff = "\n".join(patches) - final_diff_list.append(final_diff.strip()) - - if not return_remaining_files: - return final_diff_list - - remaining_files_list = [] - for file in sorted_files: - if file.filename in files_in_patches or file.filename in remaining_files_list: - continue - # a file with no patch, or with a delete-only patch, has nothing to review: - # the token budget is not what kept it out of the diff - if not file.patch or handle_patch_deletions(file.patch, file.base_file, file.head_file, - file.filename, file.edit_type) is None: - continue - remaining_files_list.append(file.filename) - return final_diff_list, remaining_files_list + file_dict[file.filename] = { + 'patch': patch, + 'tokens': new_patch_tokens, + 'edit_type': file.edit_type, + } + + return _pack_pr_multi_diffs( + file_dict, + token_handler, + max_calls, + return_remaining_files, + soft_token_budget, + ) def add_ai_metadata_to_diff_files(git_provider, pr_description_files): diff --git a/pr_agent/algo/repo_context.py b/pr_agent/algo/repo_context.py index dbf813b142..2d47e71549 100644 --- a/pr_agent/algo/repo_context.py +++ b/pr_agent/algo/repo_context.py @@ -1,3 +1,4 @@ +import re import time from collections import OrderedDict from html import escape @@ -14,6 +15,13 @@ REPO_CONTEXT_CACHE_ATTRIBUTE = "_repo_context_cache" REPO_CONTEXT_CACHE_MAX_SIZE = 256 REPO_CONTEXT_CACHE_TTL_SECONDS = 15 * 60 +_DEFAULT_MAX_SIBLING_CONTEXT_FILES = 5 +# Absolute host-controlled ceiling on sibling-repo context fetches per build. The operator-facing +# repo_context_max_sibling_files setting is clamped to this value before any fetch begins, so a +# comment command or an unexpectedly large configured value cannot produce unbounded cross-repo +# API calls. It is intentionally not user-configurable. +_HARD_MAX_SIBLING_CONTEXT_FILES = 20 +_SIBLING_REPO_SEPARATOR = ":" _REPO_CONTEXT_CACHE_MISS = object() _unsupported_repo_context_provider_classes = set() @@ -53,24 +61,26 @@ def __setitem__(self, key, value): def _get_markdown_fence(content: str) -> str: - fence = MARKDOWN_FENCE - while fence in content: - fence += "`" - return fence + if MARKDOWN_FENCE not in content: + return MARKDOWN_FENCE + min_fence_length = len(MARKDOWN_FENCE) + pattern = rf"`{{{min_fence_length},}}" + longest_run = max(match.end() - match.start() for match in re.finditer(pattern, content)) + return "`" * (longest_run + 1) def _get_repo_context_cache_key( - context_files: list, max_lines: int, from_default_branch: bool -) -> tuple[tuple[tuple[str, str], ...], int, bool]: + context_files: list, max_lines: int, context_ref: str | None +) -> tuple[tuple[tuple[str, str], ...], int, str | None]: return ( tuple((type(file_path).__name__, str(file_path)) for file_path in context_files), max_lines, - from_default_branch, + context_ref, ) def _get_repo_context_process_cache_key( - git_provider, context_files: list, max_lines: int, from_default_branch: bool + git_provider, context_files: list, max_lines: int, context_ref: str | None ) -> tuple | None: try: pr_url = git_provider.get_pr_url() @@ -81,15 +91,12 @@ def _get_repo_context_process_cache_key( return None return type(git_provider).__name__, pr_url, _get_repo_context_cache_key( - context_files, max_lines, from_default_branch + context_files, max_lines, context_ref ) def _get_repo_context_config() -> tuple[list, int] | None: context_files = get_settings().config.get("repo_context_files", []) - if not context_files: - return None - if isinstance(context_files, str): get_logger().warning( "repo_context_files should be a list of file paths; treating string value as one file path", @@ -103,6 +110,9 @@ def _get_repo_context_config() -> tuple[list, int] | None: ) return None + if not context_files: + return None + max_lines = get_settings().config.get("repo_context_max_lines", 500) try: max_lines = max(0, int(max_lines)) @@ -127,6 +137,51 @@ def _provider_supports_repo_context(git_provider) -> bool: return False +def _provider_supports_sibling_repo_context(git_provider) -> bool: + provider_method = getattr(type(git_provider), "get_sibling_repo_file_content", None) + return provider_method is not None and provider_method is not GitProvider.get_sibling_repo_file_content + + +def _sibling_repo_context_entries(context_files: list) -> list[tuple[str, str]]: + """Return the (repo_id, file_path) pairs that parse as sibling entries. + + Only entries that would really fetch from a sibling repository may force a cache bypass + (build_repo_context), so this parses with the same rules the loader uses: malformed or + non-sibling entries yield no pair and keep the revision-keyed cache usable. Whether the + provider can resolve siblings is decided separately by _provider_supports_sibling_repo_context. + """ + sibling_entries = [] + for entry in context_files: + repo_id, file_path = _parse_repo_context_file_entry(entry) + if repo_id is not None and file_path: + sibling_entries.append((repo_id, file_path)) + return sibling_entries + + +def _parse_repo_context_file_entry(entry: str | dict) -> tuple[str | None, str]: + """Parse a repo-context entry into (sibling repo id, file path). + + A string entry is always a file path in the current repository and yields (None, path). + A sibling entry is a structured ``{"repo_id": ..., "file_path": ...}`` dict naming a + repository in the same namespace/owner (GitLab: ``group/subgroup/project``, GitHub: + ``owner/repo``); its file is read from the sibling's default branch. A malformed entry + yields (None, "") and is skipped by the loader. + """ + if isinstance(entry, dict): + repo_id = entry.get("repo_id") + file_path = entry.get("file_path") + if not isinstance(repo_id, str) or not isinstance(file_path, str): + return None, "" + repo_id = repo_id.strip().strip("/") + file_path = file_path.strip().lstrip("/") + if not repo_id or not file_path: + return None, "" + return repo_id, file_path + if not isinstance(entry, str): + return None, "" + return None, entry.strip() + + def _get_provider_repo_context_cache(git_provider) -> _RepoContextCache: repo_context_cache = getattr(git_provider, REPO_CONTEXT_CACHE_ATTRIBUTE, None) if repo_context_cache is None or not isinstance(repo_context_cache, _RepoContextCache): @@ -136,17 +191,17 @@ def _get_provider_repo_context_cache(git_provider) -> _RepoContextCache: def _get_cached_repo_context( - git_provider, context_files: list, max_lines: int, from_default_branch: bool + git_provider, context_files: list, max_lines: int, context_ref: str | None ): process_cache_key = _get_repo_context_process_cache_key( - git_provider, context_files, max_lines, from_default_branch + git_provider, context_files, max_lines, context_ref ) if process_cache_key is not None: cached_repo_context = _repo_context_process_cache.get(process_cache_key, _REPO_CONTEXT_CACHE_MISS) if cached_repo_context is not _REPO_CONTEXT_CACHE_MISS: return cached_repo_context - cache_key = _get_repo_context_cache_key(context_files, max_lines, from_default_branch) + cache_key = _get_repo_context_cache_key(context_files, max_lines, context_ref) cached_repo_context = _get_provider_repo_context_cache(git_provider).get( cache_key, _REPO_CONTEXT_CACHE_MISS ) @@ -157,13 +212,13 @@ def _get_cached_repo_context( def _store_repo_context( - git_provider, context_files: list, max_lines: int, from_default_branch: bool, repo_context: str + git_provider, context_files: list, max_lines: int, context_ref: str | None, repo_context: str ) -> None: - cache_key = _get_repo_context_cache_key(context_files, max_lines, from_default_branch) + cache_key = _get_repo_context_cache_key(context_files, max_lines, context_ref) _get_provider_repo_context_cache(git_provider)[cache_key] = repo_context process_cache_key = _get_repo_context_process_cache_key( - git_provider, context_files, max_lines, from_default_branch + git_provider, context_files, max_lines, context_ref ) if process_cache_key: _repo_context_process_cache[process_cache_key] = repo_context @@ -185,45 +240,120 @@ def _read_bool_setting(key: str, default: bool) -> bool: return default +def _read_max_sibling_context_files() -> int: + try: + max_siblings = int(get_settings().config.get("repo_context_max_sibling_files", + _DEFAULT_MAX_SIBLING_CONTEXT_FILES)) + except (TypeError, ValueError): + max_siblings = _DEFAULT_MAX_SIBLING_CONTEXT_FILES + # Clamp defensively so a runtime override or an unexpectedly large configured value can never + # exceed the host-controlled hard ceiling, however the value got into the settings. + return min(max(0, max_siblings), _HARD_MAX_SIBLING_CONTEXT_FILES) + + def _load_repo_context_files( git_provider, context_files: list, from_default_branch: bool | None = None -) -> tuple[dict[str, str], bool]: +) -> tuple[list[tuple[str, str]], bool]: if from_default_branch is None: from_default_branch = _read_bool_setting("repo_context_from_default_branch", default=True) - files = {} + # Ordered (label, content) entries rather than a label-keyed dict: a local path can equal a + # sibling's rendered label, and a label-keyed mapping would silently drop one of them. + files = [] had_fetch_error = False - for file_path in context_files: - if not isinstance(file_path, str) or not file_path.strip(): - get_logger().warning("Skipping invalid repo context file path", artifact={"file_path": file_path}) - continue - - file_path = file_path.strip() - try: - content = git_provider.get_repo_file_content(file_path, from_default_branch=from_default_branch) - except Exception as e: - had_fetch_error = True - get_logger().warning(f"Failed to load repo context file: {file_path}", artifact={"error": str(e)}) + max_siblings = _read_max_sibling_context_files() + sibling_fetch_attempts = 0 + seen_sibling_pairs = set() + for entry in context_files: + repo_id, file_path = _parse_repo_context_file_entry(entry) + if not file_path: + if isinstance(entry, str) and not entry.strip(): + get_logger().warning("Skipping invalid repo context file path", artifact={"file_path": entry}) + else: + get_logger().warning( + "Ignoring malformed repo context entry (expected a local file path or a " + "{'repo_id': ..., 'file_path': ...} sibling entry)", + artifact={"entry": entry}, + ) continue + if repo_id is not None: + if not _provider_supports_sibling_repo_context(git_provider): + get_logger().warning( + f"{type(git_provider).__name__} does not support sibling repository context; " + f"skipping {repo_id}:{file_path}" + ) + continue + sibling_pair = (repo_id, file_path) + if sibling_pair in seen_sibling_pairs: + # Duplicate entries must not consume the fetch cap (or the rendered budget): + # fetch each unique sibling file once and let the cap bound the real work. + get_logger().debug(f"Skipping duplicate sibling repo context file: {repo_id}:{file_path}") + continue + seen_sibling_pairs.add(sibling_pair) + if sibling_fetch_attempts >= max_siblings: + get_logger().warning( + f"Stopping repo context at {max_siblings} sibling fetch attempt(s) (" + f"config.repo_context_max_sibling_files); skipping {repo_id}:{file_path}" + ) + continue + sibling_fetch_attempts += 1 + try: + content = git_provider.get_sibling_repo_file_content(repo_id, file_path) + except Exception: + had_fetch_error = True + # A sibling fetch can fail after the provider resolved ids, so keep the full + # traceback; a flat warning hides which request failed and why. + get_logger().exception( + f"Failed to load sibling repo context file: {repo_id}:{file_path}" + ) + continue + # Render the file under its sibling path so the model sees where it came from. + label = f"{repo_id}/{file_path}" + else: + if isinstance(entry, str) and _SIBLING_REPO_SEPARATOR in entry: + # A ':' inside a plain local path used to read as a sibling entry. Structured + # {"repo_id", "file_path"} dicts are the only sibling form now, so hint at the + # migration instead of silently splitting on ':'. + get_logger().warning( + "repo context file path contains ':' and is read as a local file; use a " + "structured {'repo_id': ..., 'file_path': ...} entry in repo_context_files to " + "load a sibling repository file", + artifact={"file_path": entry}, + ) + try: + content = git_provider.get_repo_file_content(file_path, from_default_branch=from_default_branch) + except Exception as e: + had_fetch_error = True + get_logger().warning(f"Failed to load repo context file: {file_path}", artifact={"error": str(e)}) + continue + label = file_path if not content: - get_logger().debug(f"Repo context file is empty or missing: {file_path}") + get_logger().debug(f"Repo context file is empty or missing: {label}") continue if isinstance(content, bytes): content = content.decode("utf-8", errors="replace") - files[file_path] = str(content).rstrip() + files.append((label, str(content).rstrip())) return files, had_fetch_error -def render_instruction_files(files: dict[str, str]) -> str: +def _repo_context_render_entries(files): + """Yield (path, content) pairs, accepting a label-keyed dict or ordered pairs. + + _load_repo_context_files returns ordered (label, content) pairs so a local path and a + sibling's rendered label may coexist; a dict spelling is kept for direct callers.""" + return files.items() if isinstance(files, dict) else files + + +def render_instruction_files(files: dict[str, str] | list[tuple[str, str]]) -> str: parts = [ INSTRUCTION_FILES_INTRO, "", ] - for path, content in files.items(): + for path, content in _repo_context_render_entries(files): scope = path.rsplit("/", 1)[0] if "/" in path else "repo-root" fence = _get_markdown_fence(content) parts.append(f'') @@ -237,7 +367,9 @@ def render_instruction_files(files: dict[str, str]) -> str: return "\n".join(parts) -def render_instruction_files_with_line_budget(files: dict[str, str], max_lines: int) -> str: +def render_instruction_files_with_line_budget( + files: dict[str, str] | list[tuple[str, str]], max_lines: int +) -> str: parts = [ INSTRUCTION_FILES_INTRO, "", @@ -246,7 +378,7 @@ def render_instruction_files_with_line_budget(files: dict[str, str], max_lines: if max_lines < len(parts) + 1: return "" - for path, content in files.items(): + for path, content in _repo_context_render_entries(files): scope = path.rsplit("/", 1)[0] if "/" in path else "repo-root" fence = _get_markdown_fence(content) file_header = [ @@ -289,12 +421,32 @@ def build_repo_context(git_provider) -> str: if not _provider_supports_repo_context(git_provider): return "" - from_default_branch = _read_bool_setting("repo_context_from_default_branch", default=True) - cached_repo_context = _get_cached_repo_context( - git_provider, context_files, max_lines, from_default_branch + # Sibling repositories change independently of the requesting repo, so the current-repo + # revision cannot reconstruct their default-branch content. Bypass the revision-keyed cache + # only when the build can actually attempt a sibling fetch. A zero effective sibling-fetch + # cap disables the fetches entirely, so a local + sibling config with cap 0 must keep the + # revision-keyed cache usable (otherwise disabled siblings would silently disable local + # caching too). Malformed entries and providers without sibling support likewise keep the + # cache usable. + has_sibling_entries = ( + bool(_sibling_repo_context_entries(context_files)) + and _provider_supports_sibling_repo_context(git_provider) + and _read_max_sibling_context_files() > 0 ) - if cached_repo_context is not _REPO_CONTEXT_CACHE_MISS: - return cached_repo_context + + from_default_branch = _read_bool_setting("repo_context_from_default_branch", default=True) + context_ref = None + if not has_sibling_entries: + # Resolve the revision being read once and key the cache on it: within the TTL a rebase + # or a push to the base branch must not serve file content from a commit that has moved. + # A sibling-only build never needs this lookup, whose failure would otherwise abort the + # whole context build before any cross-repository file is loaded. + context_ref = git_provider.get_repo_context_ref(from_default_branch) + cached_repo_context = _get_cached_repo_context( + git_provider, context_files, max_lines, context_ref + ) + if cached_repo_context is not _REPO_CONTEXT_CACHE_MISS: + return cached_repo_context files, had_fetch_error = _load_repo_context_files(git_provider, context_files, from_default_branch) @@ -302,6 +454,6 @@ def build_repo_context(git_provider) -> str: # Only cache when every file was fetched successfully. A transient/unexpected fetch error must # not be cached as a real result, so it is retried instead of being served until the TTL expires. - if not had_fetch_error: - _store_repo_context(git_provider, context_files, max_lines, from_default_branch, repo_context) + if not had_fetch_error and not has_sibling_entries: + _store_repo_context(git_provider, context_files, max_lines, context_ref, repo_context) return repo_context diff --git a/pr_agent/algo/review_finding_state.py b/pr_agent/algo/review_finding_state.py index 154cbed16c..768ecafe88 100644 --- a/pr_agent/algo/review_finding_state.py +++ b/pr_agent/algo/review_finding_state.py @@ -155,6 +155,21 @@ def parse_review_state(comment_body: str) -> ParsedReviewState: return ParsedReviewState(state, present=True, valid=True) +def split_review_state_marker(comment_body: str) -> tuple[str, str]: + """Split a comment into its human text and its hidden state marker. + + Returns ``(body, marker)``; ``marker`` is ``""`` when the comment carries no + complete marker. Providers that cap comment length use this to keep the + marker intact while truncating only the human text. + """ + raw = comment_body or "" + match = _STATE_MARKER_RE.search(raw) + if match is None: + return raw, "" + body = (raw[: match.start()] + raw[match.end():]).rstrip() + return body, match.group(0) + + def serialize_review_state(state: Mapping[str, Any]) -> str: """Serialize state deterministically so repeated updates are diffable.""" if not _is_valid_state(state): diff --git a/pr_agent/algo/review_merge.py b/pr_agent/algo/review_merge.py index 7f10576aba..548e577434 100644 --- a/pr_agent/algo/review_merge.py +++ b/pr_agent/algo/review_merge.py @@ -21,7 +21,8 @@ import re from typing import Any, Callable, Hashable, List, Optional -from pr_agent.algo.utils import as_review_text, is_value_no +from pr_agent.algo.comment_identity import as_review_text +from pr_agent.algo.utils import is_value_no from pr_agent.log import get_logger MAX_EFFORT = 5 diff --git a/pr_agent/algo/run_details.py b/pr_agent/algo/run_details.py index aaafa062e6..7324fe53ac 100644 --- a/pr_agent/algo/run_details.py +++ b/pr_agent/algo/run_details.py @@ -31,6 +31,8 @@ class RunDetails: # took over. Stays None when no prediction succeeded, which the renderer reads as # "nothing worth showing". model_used: Optional[str] = None + # Retain every model contributing to a merged review; keep scalar output for other tools. + models_used: list[str] = field(default_factory=list) # Sticky: once a fallback has won, a later success on the primary model must not # clear this, or the comment would hide that a fallback ran at all. fallback_used: bool = False @@ -55,6 +57,10 @@ class RunDetails: # Monotonic reference taken when the collector is installed, i.e. at the top of the # tool's run(). Monotonic so that wall-clock adjustments cannot yield a negative duration. start_time: float = field(default_factory=time.monotonic) + # Set when a tool caught its own error and, because `propagate_tool_errors` is false by + # default, returned normally anyway. Without this a caller cannot tell a run that published + # nothing because it failed from one that had nothing to say. + command_failed: bool = False @property def duration_seconds(self) -> float: @@ -90,6 +96,20 @@ def get_run_details() -> Optional[RunDetails]: return _run_details.get() +def record_command_failure() -> None: + """Mark the current run as failed, for a tool that is about to swallow its own error.""" + details = get_run_details() + if details is None: + return + details.command_failed = True + + +def command_failed() -> bool: + """Whether the current run recorded a swallowed failure.""" + details = get_run_details() + return bool(details is not None and details.command_failed) + + def record_model_used(model: str, is_fallback: bool) -> None: """Record the model that produced a successful completion.""" details = get_run_details() diff --git a/pr_agent/algo/run_output.py b/pr_agent/algo/run_output.py new file mode 100644 index 0000000000..c7feab1401 --- /dev/null +++ b/pr_agent/algo/run_output.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime, timezone +from decimal import ROUND_HALF_UP, Decimal +from importlib.metadata import PackageNotFoundError, version +from urllib.parse import urlparse + +import requests +import yaml + +from pr_agent.algo.run_details import get_run_details +from pr_agent.config_loader import get_settings +from pr_agent.log import get_logger + + +def github_action_output(output_data: dict, key_name: str): + try: + enable_output = get_settings().get("github_action_config.enable_output", False) + if isinstance(enable_output, str): + enable_output = enable_output.lower().strip() not in ("false", "0", "no", "") + if not enable_output: + return + + key_data = output_data.get(key_name, {}) + with open(os.environ["GITHUB_OUTPUT"], "a") as fh: + print(f"{key_name}={json.dumps(key_data, indent=None, ensure_ascii=False)}", file=fh) + except Exception as e: + get_logger().error(f"Failed to write to GitHub Action output: {e}") + return + + +def _push_outputs_sink_url(cfg: dict, key: str) -> str: + """Return cfg[key] if it is an absolute https URL with a host, else "" (with a warning). + + Requiring https keeps the review text, which can quote private code, off plaintext + transports. The host is not restricted: self-hosted collectors and Slack-compatible + endpoints (Mattermost, Rocket.Chat) are legitimate targets. + """ + url = cfg.get(key) or "" + if not url: + return "" + parsed = urlparse(url) + if parsed.scheme != "https" or not parsed.hostname: + # Log the key, never the value: a webhook URL is itself the credential. + get_logger().warning(f"push_outputs: ignoring {key}, expected an absolute https:// URL") + return "" + return url + + +def push_outputs(message_type: str, payload: dict | None = None, markdown: str | None = None) -> None: + """Emit a tool's output to external sinks, without calling any git-provider API. + + Controlled by the [push_outputs] config section (disabled by default). Supported channels: + "stdout" (one JSON line), "file" (append JSONL), "webhook" (POST the generic record), + "slack" (POST {"text": ...} to a Slack Incoming Webhook). Non-fatal: never raises. + """ + try: + cfg = get_settings().get("push_outputs", {}) or {} + enable = cfg.get("enable", False) + # Treat environment-variable strings "false", "0", "no", and "" as disabled. + if isinstance(enable, str): + enable = enable.lower().strip() not in ("false", "0", "no", "") + if not enable: + return + + channels = cfg.get("channels", []) or [] + record = { + "type": message_type, + "timestamp": datetime.now(timezone.utc).isoformat(), + "payload": payload or {}, + } + if markdown is not None: + record["markdown"] = markdown + + if "stdout" in channels: + try: + print(json.dumps(record, ensure_ascii=False)) + except Exception as e: + get_logger().warning(f"push_outputs: stdout failed: {type(e).__name__}") + + if "file" in channels: + try: + file_path = cfg.get("file_path", "pr-agent-outputs/reviews.jsonl") + folder = os.path.dirname(file_path) + if folder: + os.makedirs(folder, exist_ok=True) + with open(file_path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(record, ensure_ascii=False) + "\n") + except Exception as e: + get_logger().warning(f"push_outputs: file failed: {type(e).__name__}") + + # Write local channels first and network last so a failed POST cannot drop a file write. + # Never follow a redirect from a configured sink to another host (allow_redirects=False). + if "webhook" in channels: + try: + webhook_url = _push_outputs_sink_url(cfg, "webhook_url") + if webhook_url: + response = requests.post(webhook_url, json=record, timeout=5, allow_redirects=False) + if not 200 <= response.status_code < 300: + get_logger().warning(f"push_outputs: webhook failed with status {response.status_code}") + except Exception as e: + get_logger().warning(f"push_outputs: webhook failed: {type(e).__name__}") + + # Post {"text": ...} directly to Slack Incoming Webhooks without a relay service. + if "slack" in channels: + try: + slack_webhook_url = _push_outputs_sink_url(cfg, "slack_webhook_url") + if slack_webhook_url: + text = markdown if markdown is not None else json.dumps(payload or {}, ensure_ascii=False) + response = requests.post(slack_webhook_url, json={"text": text}, timeout=5, + allow_redirects=False) + if not 200 <= response.status_code < 300: + get_logger().warning(f"push_outputs: slack failed with status {response.status_code}") + except Exception as e: + get_logger().warning(f"push_outputs: slack failed: {type(e).__name__}") + except Exception as e: + # Log only the exception type: requests errors embed the (secret-bearing) URL in their text. + get_logger().warning(f"push_outputs failed: {type(e).__name__}") + + +def _render_setting_value(value) -> str: + """Render a settings value as YAML, so nested values do not become Python reprs.""" + if isinstance(value, (str, int, float, bool)) or value is None: + return str(value) + try: + return yaml.safe_dump(_to_plain(value), default_flow_style=True).strip() + except Exception: + return str(value) + + +def _to_plain(value): + """Convert Dynaconf boxes to plain dict/list so yaml can represent them.""" + if isinstance(value, dict): + return {str(k): _to_plain(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_to_plain(v) for v in value] + return value + + +def show_relevant_configurations(relevant_section: str) -> str: + skip_keys = [ + "ai_disclaimer", "ai_disclaimer_title", "ANALYTICS_FOLDER", "secret_provider", + "skip_keys", "app_id", "redirect", "trial_prefix_message", "no_eligible_message", + "identity_provider", "ALLOWED_REPOS", "APP_NAME", + ] + extra_skip_keys = get_settings().config.get("skip_keys", []) + if extra_skip_keys: + skip_keys.extend(extra_skip_keys) + skip_keys_lower = [str(key).lower() for key in skip_keys] + + markdown_text = "" + markdown_text += "\n
\n
🛠️ Relevant configurations: \n\n" + markdown_text += ( + "
These are the relevant [configurations]" + "(https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml)" + " for this tool:\n\n" + ) + markdown_text += "**[config**]\n```yaml\n\n" + for key, value in get_settings().config.items(): + if key.lower() in skip_keys_lower: + continue + markdown_text += f"{key}: {_render_setting_value(value)}\n" + markdown_text += "\n```\n" + markdown_text += f"\n**[{relevant_section}]**\n```yaml\n\n" + for key, value in get_settings().get(relevant_section, {}).items(): + if key.lower() in skip_keys_lower: + continue + markdown_text += f"{key}: {_render_setting_value(value)}\n" + markdown_text += "\n```" + markdown_text += "\n
\n" + return markdown_text + + +def _format_usd(cost: Decimal) -> str: + """Format cost at two decimals without turning a positive amount into false zero.""" + rounded = cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if cost > 0 and rounded == 0: + return "<$0.01" + return f"${rounded:.2f}" + + +def show_run_details(gfm_supported: bool) -> str: + """Render the opt-in run-details section (model, tokens, time cost, AI calls). + + Falls back to a plain, non-collapsible section when the provider does not + support GitHub-flavored markdown, so the information stays visible. + """ + details = get_run_details() + if details is None or not details.model_used: + return "" + + title = "⚙️ Agent run details" + if len(details.models_used) > 1: + lines = [f"- Models: {', '.join(details.models_used)}{' (includes fallback)' if details.fallback_used else ''}"] + else: + lines = [f"- Model: {details.model_used}{' (fallback)' if details.fallback_used else ''}"] + if details.has_token_usage: + # Drop zero-valued components because providers may omit their usage counts. + counts = [(details.prompt_tokens, "in"), (details.completion_tokens, "out"), + (details.total_tokens, "total")] + reported = [f"{value:,} {label}" for value, label in counts if value] + lines.append(f"- Tokens: {' / '.join(reported)}") + lines.append(f"- Time cost: {details.duration_seconds:.1f}s") + if details.num_ai_calls: + lines.append(f"- AI calls: {details.num_ai_calls}") + if get_settings().get("config.output_run_cost", False) and details.num_ai_calls: + if details.cost_status == "unavailable": + # Report pricing as unavailable without asserting a cause: usage may be + # missing (streaming without a final usage chunk) or uncollected by the handler. + lines.append("- Estimated API cost: unavailable (no calls could be priced)") + else: + partial = "" + if details.cost_status == "partial": + partial = (f" (partial: {details.known_cost_call_count} of " + f"{details.num_ai_calls} successful calls priced)") + lines.append(f"- Estimated API cost: {_format_usd(details.total_cost_usd)} USD{partial}") + if len(details.model_costs_usd) > 1: + for model, cost in details.model_costs_usd.items(): + lines.append(f" - {model}: {_format_usd(cost)} USD") + body = "\n".join(lines) + + if gfm_supported: + return (f"\n
\n
{title}\n\n" + f"{body}\n\n
\n") + return f"\n___\n\n**{title}**\n\n{body}\n" + + +def get_version() -> str: + # First check pyproject.toml if running directly out of the pr-agent repository + if os.path.exists("pyproject.toml"): + if sys.version_info >= (3, 11): + import tomllib + try: + with open("pyproject.toml", "rb") as f: + data = tomllib.load(f) + except (OSError, ValueError) as e: # tomllib raises TOMLDecodeError, or UnicodeDecodeError on non-UTF-8 + get_logger().warning(f"Unable to read pyproject.toml, falling back to package metadata: {e}") + else: + # only trust this file when it is pr-agent's own pyproject.toml, otherwise an + # unrelated project in the current working directory would dictate our version + project = data.get("project", {}) + if project.get("name") == "pr-agent": + if "version" in project: + return project["version"] + get_logger().warning("Version not found in pyproject.toml") + else: + get_logger().warning("Unable to determine local version from pyproject.toml") + + # Otherwise get the installed pip package version + try: + return version('pr-agent') + except PackageNotFoundError: + get_logger().warning("Unable to find package named 'pr-agent'") + return "unknown" diff --git a/pr_agent/algo/skills_loader.py b/pr_agent/algo/skills_loader.py index 14c5df2f8f..7b0d832b76 100644 --- a/pr_agent/algo/skills_loader.py +++ b/pr_agent/algo/skills_loader.py @@ -45,8 +45,8 @@ from starlette_context import context from starlette_context.errors import ContextDoesNotExistError +from pr_agent.algo.token_budget import clip_tokens from pr_agent.algo.token_handler import TokenEncoder -from pr_agent.algo.utils import clip_tokens from pr_agent.config_loader import get_settings from pr_agent.log import get_logger @@ -95,7 +95,7 @@ def _expanded_skill_paths(paths: List[str]) -> Tuple[str, ...]: def _count_tokens(text: str) -> int: - return len(TokenEncoder.get_token_encoder().encode(text)) + return len(TokenEncoder.get_token_encoder().encode(text, disallowed_special=())) def _gather_resources(skill_md_path: str) -> Tuple[SkillResource, ...]: diff --git a/pr_agent/algo/token_budget.py b/pr_agent/algo/token_budget.py new file mode 100644 index 0000000000..52438bc415 --- /dev/null +++ b/pr_agent/algo/token_budget.py @@ -0,0 +1,654 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Literal + +from jinja2 import StrictUndefined +from jinja2.sandbox import SandboxedEnvironment + +from pr_agent.algo import MAX_TOKENS +from pr_agent.algo.token_handler import TokenEncoder, TokenHandler +from pr_agent.config_loader import get_settings +from pr_agent.log import get_logger + +MESSAGE_FRAMING_TOKEN_ALLOWANCE = 16 +REPLY_FRAMING_TOKEN_ALLOWANCE = 16 +DEFAULT_TRUNCATION_MARKER = "\n...(truncated)\n" + + +def _positive_int(value) -> int | None: + if isinstance(value, int) and not isinstance(value, bool) and value > 0: + return value + return None + + +def _non_negative_int(value) -> int | None: + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + return value + return None + + +def _as_int(value, default: int = 0) -> int: + """Coerce a settings value to int, tolerating the quoted numbers TOML allows.""" + try: + return int(value) + except (TypeError, ValueError): + get_logger().warning(f"Expected a number in configuration, got {value!r}; using {default}") + return default + + +def get_max_tokens(model, ignore_max_model_tokens=False): + """ + Get the maximum number of tokens allowed for a model. + logic: + (1) If the model is in './pr_agent/algo/__init__.py', use the value from there. + (2) else if 'config.custom_model_max_tokens' is set to a positive value, use it. + (3) else if it is a GPT-5.x _thinking alias registered under its base name, use that value. + (4) else, query LiteLLM for provider-qualified and bare alias bases before the original model. + (5) else, raise an error. + + For all cases, we further limit the number of tokens to 'config.max_model_tokens' if it is set. + This aims to improve the algorithmic quality, as the AI model degrades in performance when the input is too long. + Pass ignore_max_model_tokens=True to keep the unreduced value, for sites that deliberately use the + raw model context size rather than the conservative clamp. + """ + settings = get_settings() + custom_max_tokens = _as_int(settings.config.custom_model_max_tokens) + # Resolve GPT-6 Astra aliases before diff token accounting, just as the handler does. + # Preserve explicit custom limits for provider aliases that were not in the registry. + model_base = model + while model_base.startswith(("openai/", "azure/")): + model_base = model_base.removeprefix("openai/").removeprefix("azure/") + if custom_max_tokens <= 0 and model_base.removesuffix("_thinking") == "gpt-6-astra": + model = "gpt-6-astra" + # Normalize GPT-5.x _thinking aliases before token-limit lookup to match + # LiteLLMAIHandler request normalization. + model_for_max_tokens = model + litellm_lookup_models = (model,) + if isinstance(model, str): + tmp = model + while tmp.startswith(("openai/", "azure/")): + tmp = tmp.removeprefix("openai/").removeprefix("azure/") + if tmp.startswith("gpt-5") and "_thinking" in tmp: + model_for_max_tokens = tmp.replace("_thinking", "") + settings_get = getattr(settings, "get", None) + azure_mode = callable(settings_get) and ( + settings_get("OPENAI.API_TYPE", None) == "azure" + or bool(settings_get("AZURE_AD.CLIENT_ID", None)) + ) + if azure_mode or model.startswith("azure/"): + provider_prefix = "azure/" + else: + provider_prefix = "openai/" + provider_model = provider_prefix + model_for_max_tokens + litellm_lookup_models = tuple(dict.fromkeys((provider_model, model_for_max_tokens, model))) + if model in MAX_TOKENS: + max_tokens_model = MAX_TOKENS[model] + elif custom_max_tokens > 0: + max_tokens_model = custom_max_tokens + elif model_for_max_tokens in MAX_TOKENS: + max_tokens_model = MAX_TOKENS[model_for_max_tokens] + else: + # Fallback: ask LiteLLM for the model's metadata before giving up. + max_tokens_model = 0 + import litellm + # Try provider-qualified and bare bases before the raw alias. + for lookup_model in litellm_lookup_models: + try: + model_info = litellm.get_model_info(lookup_model) + except Exception: + get_logger().debug(f"litellm.get_model_info could not resolve model '{lookup_model}'") + model_info = None + if model_info: + litellm_max = model_info.get("max_input_tokens") + try: + parsed_max_tokens = int(litellm_max) + except (TypeError, ValueError, OverflowError): + continue + if parsed_max_tokens > 0: + max_tokens_model = parsed_max_tokens + get_logger().debug( + f"Resolved max_input_tokens for '{model}' from litellm " + f"(lookup '{lookup_model}'): {max_tokens_model}" + ) + break + + if max_tokens_model <= 0: + get_logger().error( + f"Model {model} is not defined in MAX_TOKENS in ./pr_agent/algo/__init__.py" + f" and no custom_model_max_tokens is set" + ) + raise Exception( + f"Ensure {model} is defined in MAX_TOKENS in ./pr_agent/algo/__init__.py" + f" or set a positive value for it in config.custom_model_max_tokens" + ) + + max_model_tokens = _as_int(settings.config.max_model_tokens) if settings.config.max_model_tokens else 0 + if max_model_tokens > 0 and not ignore_max_model_tokens: + max_tokens_model = min(max_model_tokens, max_tokens_model) + return max_tokens_model + + +def clip_tokens(text: str, max_tokens: int, add_three_dots=True, num_input_tokens=None, delete_last_line=False) -> str: + """ + Clip the number of tokens in a string to a maximum number of tokens. + + This function limits text to a specified token count by calculating the approximate + character-to-token ratio and truncating the text accordingly. A safety factor of 0.9 + (10% reduction) is applied to ensure the result stays within the token limit. + + Args: + text (str): The string to clip. If empty or None, returns the input unchanged. + max_tokens (int): The maximum number of tokens allowed in the string. + If negative, returns an empty string. + add_three_dots (bool, optional): Whether to add "\\n...(truncated)" at the end + of the clipped text to indicate truncation. + Defaults to True. + num_input_tokens (int, optional): Pre-computed number of tokens in the input text. + If provided, skips token encoding step for efficiency. + If None, tokens will be counted using TokenEncoder. + Defaults to None. + delete_last_line (bool, optional): Whether to remove the last line from the + clipped content before adding truncation indicator. + Useful for ensuring clean breaks at line boundaries. + Defaults to False. + + Returns: + str: The clipped string. Returns original text if: + - Text is empty/None + - Token count is within limit + - An error occurs during processing + + Returns empty string if max_tokens <= 0. + + Examples: + Basic usage: + >>> text = "This is a sample text that might be too long" + >>> result = clip_tokens(text, max_tokens=10) + >>> print(result) + This is a sample... + (truncated) + + Without truncation indicator: + >>> result = clip_tokens(text, max_tokens=10, add_three_dots=False) + >>> print(result) + This is a sample + + With pre-computed token count: + >>> result = clip_tokens(text, max_tokens=5, num_input_tokens=15) + >>> print(result) + This... + (truncated) + + With line deletion: + >>> multiline_text = "Line 1\\nLine 2\\nLine 3" + >>> result = clip_tokens(multiline_text, max_tokens=3, delete_last_line=True) + >>> print(result) + Line 1 + Line 2 + ... + (truncated) + + Notes: + The function uses a safety factor of 0.9 (10% reduction) to ensure the + result stays within the token limit, as character-to-token ratios can vary. + If token encoding fails, the original text is returned with a warning logged. + """ + try: + max_tokens = int(max_tokens) + except (TypeError, ValueError, OverflowError): + get_logger().warning( + f"clip_tokens got a non-numeric max_tokens ({max_tokens!r}); returning the text " + f"unclipped, which may exceed the model's context window") + return text + + if not text: + return text + + try: + if num_input_tokens is None: + encoder = TokenEncoder.get_token_encoder() + num_input_tokens = len(encoder.encode(text, disallowed_special=())) + if num_input_tokens <= max_tokens: + return text + if max_tokens < 0: + return "" + + # calculate the number of characters to keep + num_chars = len(text) + chars_per_token = num_chars / num_input_tokens + factor = 0.9 # reduce by 10% to be safe + num_output_chars = int(factor * chars_per_token * max_tokens) + + # clip the text + if num_output_chars > 0: + clipped_text = text[:num_output_chars] + if delete_last_line: + clipped_text = clipped_text.rsplit("\n", 1)[0] + if add_three_dots: + clipped_text += "\n...(truncated)" + else: # text is empty + clipped_text = "" + + return clipped_text + except Exception as e: + get_logger().warning(f"Failed to clip tokens: {e}") + return text + + +@dataclass(frozen=True) +class FittedPrompt: + """Represent a normalized prompt pair whose optional text fits one model attempt.""" + + optional_text: str + system_prompt: str + user_prompt: str + input_tokens: int + + +@dataclass(frozen=True) +class AttemptTokenBudget: + """Track token accounting and output headroom for one attempted model.""" + + model: str + source_token_handler: object + token_handler: object + context_window: int + output_token_reserve: Callable[[str, int], int] | None = None + + @classmethod + def for_attempt( + cls, + model: str, + token_handler, + *, + output_token_reserve=None, + ignore_max_model_tokens: bool = False, + ) -> AttemptTokenBudget: + """Create an immutable budget whose tokenizer and window belong to ``model``.""" + bound_token_handler = ( + token_handler.for_model(model) if isinstance(token_handler, TokenHandler) else token_handler + ) + return cls( + model=model, + source_token_handler=token_handler, + token_handler=bound_token_handler, + context_window=get_max_tokens(model, ignore_max_model_tokens=ignore_max_model_tokens), + output_token_reserve=output_token_reserve, + ) + + @classmethod + def for_prompt_attempt( + cls, + model: str, + pr, + variables: dict, + system_template: str, + user_template: str, + *, + ai_handler, + image_path: str | None = None, + output_token_reserve=None, + ignore_max_model_tokens: bool = False, + ) -> AttemptTokenBudget: + """Create a model-bound budget from the exact fixed request fields.""" + token_handler = TokenHandler( + pr, + variables, + system_template, + user_template, + model=model, + ) + budget = cls.for_attempt( + model, + token_handler, + output_token_reserve=output_token_reserve, + ignore_max_model_tokens=ignore_max_model_tokens, + ) + system_prompt, user_prompt = budget.render_prompt_templates(variables) + prepared = budget.prepare_request( + ai_handler, + system_prompt, + user_prompt, + image_path=image_path, + ) + token_handler.prompt_tokens = prepared.input_tokens + return budget + + @property + def prompt_tokens(self) -> int: + prompt_tokens = getattr(self.token_handler, "prompt_tokens", 0) + if isinstance(prompt_tokens, int) and not isinstance(prompt_tokens, bool) and prompt_tokens >= 0: + return prompt_tokens + return 0 + + def resolve_output_reserve( + self, + default_output_tokens: int, + *, + preserve_minimum: bool = False, + ) -> int: + """Resolve output headroom without flattening consumer-specific minimums.""" + resolved = None + if callable(self.output_token_reserve): + try: + resolved = _positive_int(self.output_token_reserve(self.model, default_output_tokens)) + except Exception as error: + get_logger().debug(f"Failed to resolve the output token reserve for {self.model}: {error}") + + if resolved is None: + resolved = default_output_tokens + if preserve_minimum: + resolved = max(resolved, default_output_tokens) + return resolved + + def available_tokens( + self, + default_output_tokens: int, + *, + preserve_minimum: bool = False, + prompt_tokens: int | None = None, + clamp: bool = True, + ) -> int: + """Return remaining input capacity; raw mode preserves packers' strict hard stops.""" + fixed_prompt_tokens = self.prompt_tokens if prompt_tokens is None else prompt_tokens + output_reserve = self.resolve_output_reserve( + default_output_tokens, + preserve_minimum=preserve_minimum, + ) + available = self.context_window - output_reserve - fixed_prompt_tokens + return max(available, 0) if clamp else available + + def input_token_limit( + self, + default_output_tokens: int, + *, + preserve_minimum: bool = False, + additional_input_reserve: int = 0, + ) -> int: + """Return the complete request-input limit for this attempt.""" + output_reserve = self.resolve_output_reserve( + default_output_tokens, + preserve_minimum=preserve_minimum, + ) + if not isinstance(additional_input_reserve, int) or isinstance(additional_input_reserve, bool): + additional_input_reserve = 0 + return max(self.context_window - output_reserve - max(additional_input_reserve, 0), 0) + + def require_input_capacity( + self, + default_output_tokens: int, + *, + preserve_minimum: bool = False, + ) -> int: + """Return remaining input capacity or fail this model attempt.""" + available = self.available_tokens( + default_output_tokens, + preserve_minimum=preserve_minimum, + clamp=False, + ) + if available <= 0: + raise ValueError(f"The required prompt leaves no input capacity for {self.model}") + return available + + def count_tokens(self, text: str, *, force_accurate: bool = False) -> int: + """Count text with the tokenizer bound to this attempt.""" + if force_accurate: + return self.token_handler.count_tokens(text, force_accurate=True) + return self.token_handler.count_tokens(text) + + def normalize_request_prompts( + self, + ai_handler, + system_prompt: str, + user_prompt: str, + ) -> tuple[str, str]: + """Return the prompt strings that the active handler will dispatch.""" + normalize = getattr(ai_handler, "normalize_request_prompts", None) + if not callable(normalize): + return system_prompt, user_prompt + try: + normalized = normalize(self.model, system_prompt, user_prompt) + except Exception as error: + get_logger().debug(f"Failed to normalize prompts for {self.model}: {error}") + return system_prompt, user_prompt + if ( + isinstance(normalized, tuple) + and len(normalized) == 2 + and all(isinstance(prompt, str) for prompt in normalized) + ): + return normalized + get_logger().debug(f"Ignoring unusable prompt normalization result for {self.model}") + return system_prompt, user_prompt + + def render_prompt_templates(self, variables: dict) -> tuple[str, str]: + """Render this attempt handler's templates with the supplied variables.""" + # Preserve plain-text model input in a sandbox; HTML escaping would corrupt code and diffs. + environment = SandboxedEnvironment(undefined=StrictUndefined) + system_prompt = environment.from_string(self.token_handler.system).render(variables) + user_prompt = environment.from_string(self.token_handler.user).render(variables) + return system_prompt, user_prompt + + def count_request_tokens( + self, + system_prompt: str, + user_prompt: str, + *, + image_path: str | None = None, + messages: list[dict] | None = None, + ) -> int: + """Count the final request with the attempt tokenizer, framing, and image input.""" + if messages is None: + user_content = user_prompt + if image_path: + user_content = [ + {"type": "text", "text": user_prompt}, + {"type": "image_url", "image_url": {"url": image_path}}, + ] + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content}, + ] + content_tokens = 0 + image_count = 0 + for message in messages: + content = message.get("content", "") + if isinstance(content, str): + content_tokens += self.count_tokens(content) + continue + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text" and isinstance(block.get("text"), str): + content_tokens += self.count_tokens(block["text"]) + elif block.get("type") == "image_url": + image_url = block.get("image_url") + if isinstance(image_url, dict) and isinstance(image_url.get("url"), str): + content_tokens += self.count_tokens(image_url["url"]) + image_count += 1 + request_tokens = ( + content_tokens + + MESSAGE_FRAMING_TOKEN_ALLOWANCE * len(messages) + + REPLY_FRAMING_TOKEN_ALLOWANCE + ) + if image_count: + raw_allowance = get_settings().get("config.image_input_token_allowance") + image_allowance = _non_negative_int(raw_allowance) + if image_allowance is None: + raise ValueError( + "config.image_input_token_allowance must be a non-negative integer" + ) + request_tokens += image_count * image_allowance + return request_tokens + + def prepare_request( + self, + ai_handler, + system_prompt: str, + user_prompt: str, + *, + optional_text: str = "", + image_path: str | None = None, + ) -> FittedPrompt: + """Normalize and count the exact prompt pair that will be dispatched.""" + system_prompt, user_prompt = self.normalize_request_prompts( + ai_handler, + system_prompt, + user_prompt, + ) + messages = None + build_messages = getattr(ai_handler, "build_request_messages", None) + if callable(build_messages): + messages = build_messages( + self.model, + system_prompt, + user_prompt, + image_path=image_path, + ) + return FittedPrompt( + optional_text=optional_text, + system_prompt=system_prompt, + user_prompt=user_prompt, + input_tokens=self.count_request_tokens( + system_prompt, + user_prompt, + image_path=image_path, + messages=messages, + ), + ) + + def fit_optional_text( + self, + optional_text: str, + render: Callable[[str], tuple[str, str]], + *, + ai_handler, + default_output_tokens: int, + preserve_minimum: bool = False, + additional_input_reserve: int = 0, + image_path: str | None = None, + keep: Literal["prefix", "suffix"] = "prefix", + truncation_marker: str = DEFAULT_TRUNCATION_MARKER, + ) -> FittedPrompt: + """Fit one optional prompt field and verify the exact normalized request.""" + if keep not in {"prefix", "suffix"}: + raise ValueError(f"Unsupported optional-text retention policy: {keep}") + + input_limit = self.input_token_limit( + default_output_tokens, + preserve_minimum=preserve_minimum, + additional_input_reserve=additional_input_reserve, + ) + + def prepare(candidate: str) -> FittedPrompt: + system_prompt, user_prompt = render(candidate) + return self.prepare_request( + ai_handler, + system_prompt, + user_prompt, + optional_text=candidate, + image_path=image_path, + ) + + full_prompt = prepare(optional_text) + if full_prompt.input_tokens <= input_limit: + return full_prompt + + empty_prompt = prepare("") + if empty_prompt.input_tokens > input_limit: + raise ValueError(f"The required prompt exceeds the token limit for {self.model}") + if not optional_text: + return empty_prompt + + marker_prompt = prepare(truncation_marker) + if marker_prompt.input_tokens > input_limit: + raise ValueError( + f"The truncation marker does not fit the token limit for {self.model}" + ) + best_prompt = marker_prompt + + encoder = getattr(self.token_handler, "encoder", None) + encode = getattr(encoder, "encode", None) + decode = getattr(encoder, "decode", None) + if callable(encode) and callable(decode): + encoded = encode(optional_text, disallowed_special=()) + + def retain(count: int) -> str: + retained = encoded[-count:] if keep == "suffix" else encoded[:count] + return decode(retained) + else: + encoded = list(optional_text) + + def retain(count: int) -> str: + retained = encoded[-count:] if keep == "suffix" else encoded[:count] + return "".join(retained) + + # Start near the available token capacity, then use each exact count to + # reduce the retained token slice. Avoid assuming that rendered BPE counts + # are monotonic in the number of source characters. + marker_capacity = max(input_limit - marker_prompt.input_tokens, 0) + keep_tokens = min(len(encoded) - 1, marker_capacity) + while keep_tokens > 0: + retained_text = retain(keep_tokens) + if keep == "suffix": + candidate = truncation_marker + retained_text.lstrip() + else: + candidate = retained_text.rstrip() + truncation_marker + candidate_prompt = prepare(candidate) + if candidate_prompt.input_tokens <= input_limit: + best_prompt = candidate_prompt + break + candidate_cost = max( + candidate_prompt.input_tokens - marker_prompt.input_tokens, + 1, + ) + scaled_keep = keep_tokens * marker_capacity // candidate_cost + keep_tokens = min(keep_tokens - 1, scaled_keep) + + if best_prompt.input_tokens > input_limit: + raise ValueError(f"Failed to fit the optional prompt text for {self.model}") + return best_prompt + + def fit_prompt_variable( + self, + variables: dict, + variable_name: str, + optional_text: str, + *, + ai_handler, + default_output_tokens: int, + preserve_minimum: bool = False, + additional_input_reserve: int = 0, + image_path: str | None = None, + keep: Literal["prefix", "suffix"] = "prefix", + truncation_marker: str = DEFAULT_TRUNCATION_MARKER, + ) -> FittedPrompt: + """Fit one prompt variable without mutating retry-shared state.""" + environment = SandboxedEnvironment(undefined=StrictUndefined) + system_template = environment.from_string(self.token_handler.system) + user_template = environment.from_string(self.token_handler.user) + + def render(candidate: str) -> tuple[str, str]: + attempt_variables = variables.copy() + attempt_variables[variable_name] = candidate + return system_template.render(attempt_variables), user_template.render(attempt_variables) + + return self.fit_optional_text( + optional_text, + render, + ai_handler=ai_handler, + default_output_tokens=default_output_tokens, + preserve_minimum=preserve_minimum, + additional_input_reserve=additional_input_reserve, + image_path=image_path, + keep=keep, + truncation_marker=truncation_marker, + ) + + def matches(self, model: str, source_token_handler: object) -> bool: + """Return whether prepared data belongs to the same model and source handler.""" + return self.model == model and ( + source_token_handler is self.source_token_handler or source_token_handler is self.token_handler + ) diff --git a/pr_agent/algo/token_handler.py b/pr_agent/algo/token_handler.py index 135e132121..78cedc0234 100644 --- a/pr_agent/algo/token_handler.py +++ b/pr_agent/algo/token_handler.py @@ -66,7 +66,7 @@ class TokenHandler: CLAUDE_MODEL = "claude-3-7-sonnet-20250219" CLAUDE_MAX_CONTENT_SIZE = 9_000_000 # Maximum allowed content size (9MB) for Claude API - def __init__(self, pr=None, vars: dict = {}, system="", user="", model=None): + def __init__(self, pr=None, vars: dict | None = None, system="", user="", model=None): """ Initializes the TokenHandler object. @@ -77,11 +77,25 @@ def __init__(self, pr=None, vars: dict = {}, system="", user="", model=None): - user: The user string. - model: Optional model name whose tokenizer should be used. """ - self.encoder = TokenEncoder.get_token_encoder(model) + if vars is None: + vars = {} + self.model = model or get_settings().config.model + self.pr = pr + self.vars = vars + self.system = system + self.user = user + self.prompt_tokens = 0 + self.encoder = TokenEncoder.get_token_encoder(self.model) if pr is not None: self.prompt_tokens = self._get_system_user_tokens(pr, self.encoder, vars, system, user) + def for_model(self, model: str): + """Return a handler bound to ``model`` without mutating this handler.""" + if model == self.model: + return self + return TokenHandler(self.pr, self.vars, self.system, self.user, model=model) + def _get_system_user_tokens(self, pr, encoder, vars: dict, system, user): """ Calculates the number of tokens in the system and user strings. @@ -100,8 +114,8 @@ def _get_system_user_tokens(self, pr, encoder, vars: dict, system, user): environment = Environment(undefined=StrictUndefined) system_prompt = environment.from_string(system).render(vars) user_prompt = environment.from_string(user).render(vars) - system_prompt_tokens = len(encoder.encode(system_prompt)) - user_prompt_tokens = len(encoder.encode(user_prompt)) + system_prompt_tokens = len(encoder.encode(system_prompt, disallowed_special=())) + user_prompt_tokens = len(encoder.encode(user_prompt, disallowed_special=())) return system_prompt_tokens + user_prompt_tokens except Exception as e: get_logger().error(f"Error in _get_system_user_tokens: {e}") @@ -163,7 +177,7 @@ def _get_token_count_by_model_type(self, patch: str, default_estimate: int) -> i Returns: int: The calculated token count. """ - model_name = get_settings().config.model.lower() + model_name = str(getattr(self, "model", None) or get_settings().config.model).lower() if ModelTypeValidator.is_openai_model(model_name) and get_settings(use_context=False).get('openai.key'): return default_estimate diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index 5b0d4cf175..abced30823 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -2,39 +2,27 @@ import copy import difflib -import hashlib import html import json -import os import re -import string -import sys import textwrap -import time -import traceback -from datetime import datetime, timezone -from decimal import ROUND_HALF_UP, Decimal +from datetime import datetime from enum import Enum -from importlib.metadata import PackageNotFoundError, version -from typing import Any, Iterable, List, Tuple, TypedDict -from urllib.parse import quote, unquote, urlparse +from typing import Any, List, Tuple, TypedDict +from urllib.parse import quote, unquote import html2text -import requests import yaml from pydantic import BaseModel -from starlette_context import context -from pr_agent.algo import MAX_TOKENS +import pr_agent.algo.comment_identity as _ci from pr_agent.algo.git_patch_processing import ( extract_hunk_headers, extract_hunk_lines_from_patch, to_hunk_only_patch, ) -from pr_agent.algo.run_details import get_run_details -from pr_agent.algo.token_handler import TokenEncoder from pr_agent.algo.types import FilePatchInfo -from pr_agent.config_loader import get_settings, get_verbosity_level, global_settings +from pr_agent.config_loader import get_settings, get_verbosity_level from pr_agent.log import get_logger _ENCODED_USER_TEXT_PREFIX = "__pr_agent_encoded_text__:" @@ -82,132 +70,6 @@ class TodoItem(TypedDict): content: str -class PRReviewHeader(str, Enum): - REGULAR = "## PR Reviewer Guide" - INCREMENTAL = "## Incremental PR Reviewer Guide" - - -class PRReviewIdentity(str, Enum): - REGULAR = "" - INCREMENTAL = "" - - -class PRCodeSuggestionsHeader(str, Enum): - SUMMARY = "## PR Code Suggestions ✨" - - -class PRCodeSuggestionsIdentity(str, Enum): - SUMMARY = "" - NO_SUGGESTIONS = "" - UNANCHORED = "" - - -_ALL_COMMENT_IDENTITIES = ( - PRReviewIdentity.REGULAR.value, - PRReviewIdentity.INCREMENTAL.value, - PRCodeSuggestionsIdentity.SUMMARY.value, - PRCodeSuggestionsIdentity.NO_SUGGESTIONS.value, - PRCodeSuggestionsIdentity.UNANCHORED.value, -) -_REVIEW_IDENTITY_HEADER_LINES = 5 -_MARKDOWN_PUNCTUATION_ESCAPE_TABLE = str.maketrans( - {character: f"\\{character}" for character in string.punctuation} -) - - -def _get_configured_heading(setting_name: str, default_heading: str) -> str: - configured_heading = get_settings().get(setting_name) - if ( - not isinstance(configured_heading, str) - or not configured_heading.strip() - or configured_heading.splitlines() != [configured_heading] - ): - get_logger().warning( - f"Invalid {setting_name}; using the default heading" - ) - configured_heading = default_heading - return configured_heading.strip() - - -def format_pr_review_header(incremental: bool = False) -> str: - """Return the visible review heading while keeping identity out of presentation.""" - default_heading = PRReviewHeader.REGULAR.value.removeprefix("## ") - heading = _get_configured_heading("pr_reviewer.review_heading", default_heading) - incremental_prefix = "Incremental " if incremental else "" - return f"## {incremental_prefix}{heading} 🔍" - - -def format_pr_code_suggestions_header(markdown_level: int = 2) -> str: - """Return the visible suggestions heading while keeping identity out of presentation.""" - default_heading = ( - PRCodeSuggestionsHeader.SUMMARY.value - .removeprefix("## ") - .removesuffix(" ✨") - ) - heading = _get_configured_heading( - "pr_code_suggestions.suggestions_heading", - default_heading, - ) - markdown_prefix = "#" * markdown_level - return f"{markdown_prefix} {heading} ✨" - - -def format_pr_questions_header(*, escape_markdown: bool = True) -> str: - """Return the visible heading for top-level /ask answers.""" - heading = _get_configured_heading("pr_questions.ask_heading", "Ask") - if escape_markdown: - heading = heading.translate(_MARKDOWN_PUNCTUATION_ESCAPE_TABLE) - return f"### **{heading}** ❓" - - -def comment_matches_identity(body: str, identity: str) -> bool: - """Match hidden markers only as exact lines near the top; legacy headers as prefixes.""" - if not isinstance(body, str) or not isinstance(identity, str) or not identity: - return False - if identity.startswith("") - match = r.search(s) - - up_to_commit_txt = "" - if match: - up_to_commit_txt = f" up to commit {match.group(0)[4:-3].strip()}" - return up_to_commit_txt - async def _prepare_prediction(self, model: str) -> dict: + output_token_reserve = getattr(self.ai_handler, "get_output_token_reserve", None) + attempt_variables = copy.deepcopy(self.vars) + attempt_variables["diff"] = "" + attempt_variables["diff_no_line_numbers"] = "" + self._suggestion_attempt_budget = AttemptTokenBudget.for_prompt_attempt( + model, + getattr(self.git_provider, "pr", None), + attempt_variables, + self.pr_code_suggestions_prompt_system, + self.pr_code_suggestions_prompt_user, + ai_handler=self.ai_handler, + output_token_reserve=output_token_reserve, + ) + self._suggestion_attempt_budget.require_input_capacity( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + preserve_minimum=True, + ) self.patches_diff = get_pr_diff(self.git_provider, - self.token_handler, + self._suggestion_attempt_budget.token_handler, model, add_line_numbers_to_hunks=True, - disable_extra_lines=False) + disable_extra_lines=False, + output_token_reserve=output_token_reserve) self.patches_diff_list = [self.patches_diff] self.patches_diff_no_line_number = self.remove_line_numbers([self.patches_diff])[0] @@ -816,12 +861,40 @@ async def _prepare_prediction(self, model: str) -> dict: return data async def _get_prediction(self, model: str, patches_diff: str, patches_diff_no_line_number: str) -> dict: - variables = copy.deepcopy(self.vars) - variables["diff"] = patches_diff # update diff - variables["diff_no_line_numbers"] = patches_diff_no_line_number # update diff - environment = Environment(undefined=StrictUndefined) - system_prompt = environment.from_string(self.pr_code_suggestions_prompt_system).render(variables) - user_prompt = environment.from_string(self.pr_code_suggestions_prompt_user).render(variables) + budget = getattr(self, "_suggestion_attempt_budget", None) + if budget is None or budget.model != model: + attempt_variables = copy.deepcopy(self.vars) + attempt_variables["diff"] = "" + attempt_variables["diff_no_line_numbers"] = "" + budget = AttemptTokenBudget.for_prompt_attempt( + model, + getattr(self.git_provider, "pr", None), + attempt_variables, + self.pr_code_suggestions_prompt_system, + self.pr_code_suggestions_prompt_user, + ai_handler=self.ai_handler, + output_token_reserve=getattr(self.ai_handler, "get_output_token_reserve", None), + ) + self._suggestion_attempt_budget = budget + + def render(diff_no_line_numbers: str) -> tuple[str, str]: + variables = copy.deepcopy(self.vars) + variables["diff"] = patches_diff + variables["diff_no_line_numbers"] = diff_no_line_numbers + return budget.render_prompt_templates(variables) + + fitted = budget.fit_optional_text( + patches_diff_no_line_number, + render, + ai_handler=self.ai_handler, + default_output_tokens=OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, + preserve_minimum=True, + ) + if fitted.optional_text != patches_diff_no_line_number: + raise ValueError( + f"The complete suggestion chunk does not fit the token limit for {model}" + ) + system_prompt, user_prompt = fitted.system_prompt, fitted.user_prompt response, finish_reason = await self.ai_handler.chat_completion( model=model, temperature=get_settings().config.temperature, system=system_prompt, user=user_prompt) if not get_settings().config.publish_output: @@ -836,8 +909,8 @@ async def _get_prediction(self, model: str, patches_diff: str, patches_diff_no_l if response_reflect: await self.analyze_self_reflection_response(data, response_reflect) else: - # get_logger().error(f"Could not self-reflect on suggestions. using default score 7") - for i, suggestion in enumerate(data["code_suggestions"]): + get_logger().warning("Could not self-reflect on suggestions; using default score 7") + for suggestion in data["code_suggestions"]: suggestion["score"] = 7 suggestion["score_why"] = "" @@ -880,7 +953,18 @@ async def _self_reflect_with_fallback(self, suggestion_list: List, patches_diff: async def analyze_self_reflection_response(self, data, response_reflect): response_reflect_yaml = load_yaml(response_reflect) + if not isinstance(response_reflect_yaml, dict): + get_logger().warning( + "Self-reflection feedback was not a mapping; line anchors will not be resolved" + ) + return code_suggestions_feedback = response_reflect_yaml.get("code_suggestions", []) + if not isinstance(code_suggestions_feedback, list): + get_logger().warning( + "Self-reflection feedback 'code_suggestions' was not a list; " + "line anchors will not be resolved" + ) + return if code_suggestions_feedback and len(code_suggestions_feedback) == len(data["code_suggestions"]): for i, suggestion in enumerate(data["code_suggestions"]): try: @@ -911,7 +995,7 @@ async def analyze_self_reflection_response(self, data, response_reflect): get_logger().error(f"Failed to log suggestion statistics, error: {e}") pass - except Exception as e: # + except Exception: get_logger().error(f"Error processing suggestion score {i}", artifact={"suggestion": suggestion, "code_suggestions_feedback": code_suggestions_feedback[i]}) @@ -931,6 +1015,11 @@ async def analyze_self_reflection_response(self, data, response_reflect): suggestion['existing_code'] = "" except Exception as e: get_logger().error(f"Error processing suggestion {i + 1}, error: {e}") + else: + get_logger().warning( + f"Self-reflection feedback covered {len(code_suggestions_feedback)} suggestion(s) instead of " + f"{len(data['code_suggestions'])}; line anchors will not be resolved" + ) @staticmethod def _truncate_if_needed(suggestion): @@ -946,6 +1035,81 @@ def _truncate_if_needed(suggestion): suggestion['_is_truncated'] = True return suggestion + @staticmethod + def _parse_line_number(value) -> Optional[int]: + """Convert an anchor value to an int, or None when it cannot be a line number. + + Rejects booleans, fractional floats (int() truncates them), and non-finite + floats (int() raises OverflowError) instead of silently coercing them. + """ + if isinstance(value, bool): + return None + if isinstance(value, float) and (not math.isfinite(value) or not value.is_integer()): + return None + try: + return int(value) + except (TypeError, ValueError, OverflowError): + return None + + def _is_suggestion_line_range_valid(self, suggestion: dict) -> bool: + relevant_lines_start = self._parse_line_number(suggestion.get('relevant_lines_start')) + relevant_lines_end = self._parse_line_number(suggestion.get('relevant_lines_end')) + relevant_file = suggestion.get('relevant_file') + if relevant_lines_start is None or relevant_lines_end is None: + get_logger().warning("Skipping a suggestion without a valid line range", + artifact={'relevant_file': relevant_file, + 'one_sentence_summary': suggestion.get('one_sentence_summary')}) + return False + if relevant_lines_start < 1 or relevant_lines_end < relevant_lines_start: + get_logger().warning("Skipping a suggestion with an invalid line range", + artifact={'relevant_file': relevant_file, + 'one_sentence_summary': suggestion.get('one_sentence_summary'), + 'relevant_lines_start': relevant_lines_start, + 'relevant_lines_end': relevant_lines_end}) + return False + suggestion['relevant_lines_start'] = relevant_lines_start + suggestion['relevant_lines_end'] = relevant_lines_end + if not self._is_suggestion_line_range_in_diff( + relevant_file, relevant_lines_start, relevant_lines_end): + return False + return True + + def _is_suggestion_line_range_in_diff( + self, + relevant_file, + relevant_lines_start: int, + relevant_lines_end: int) -> bool: + """Reject suggestions whose range cannot be resolved in the relevant file. + + The model reflects on the extended patch, so accept context outside the + raw hunk when the complete head file contains it, as in _validate_suggestion. + Without complete file content, use the raw hunk as the reference. + """ + if not isinstance(relevant_file, str) or not relevant_file.strip(): + get_logger().warning("Skipping a suggestion whose file is missing", + artifact={'relevant_file': relevant_file, + 'one_sentence_summary': None}) + return False + diff_file = self._get_diff_file(relevant_file.strip()) + if diff_file is None: + get_logger().warning("Skipping a suggestion whose file is not part of the PR diff", + artifact={'relevant_file': relevant_file.strip(), + 'relevant_lines_start': relevant_lines_start, + 'relevant_lines_end': relevant_lines_end}) + return False + if diff_file.head_file and getattr(diff_file, "head_file_is_complete", True): + range_resolvable = relevant_lines_end <= len(diff_file.head_file.splitlines()) + else: + range_resolvable = self._get_patch_range_lines( + diff_file.patch, relevant_lines_start, relevant_lines_end) is not None + if not range_resolvable: + get_logger().warning("Skipping a suggestion whose line range is not within the file", + artifact={'relevant_file': relevant_file.strip(), + 'relevant_lines_start': relevant_lines_start, + 'relevant_lines_end': relevant_lines_end}) + return False + return True + def _prepare_pr_code_suggestions(self, predictions: str) -> Dict: data = load_yaml(predictions.strip(), keys_fix_yaml=["relevant_file", "suggestion_content", "existing_code", "improved_code"], @@ -1055,7 +1219,9 @@ def _limit_suggestions_per_file(self, suggestions: List[Dict]) -> List[Dict]: async def push_inline_code_suggestions(self, data, include_coverage_footer: bool = True) -> None: code_suggestions = [] + artifact_suggestions = [] fallback_comments = [] + artifact_batch_published = False coverage_footer = self._get_suggestions_coverage_footer() if include_coverage_footer else "" supports_suggestions_artifact = self.git_provider.supports_code_suggestions_artifact() is True @@ -1095,6 +1261,8 @@ async def push_inline_code_suggestions(self, data, include_coverage_footer: bool existing_code if new_code_snippet else None) if new_code_snippet and has_valid_anchor: new_code_snippet = self.dedent_code(relevant_file, relevant_lines_start, new_code_snippet) + existing_code = self.dedent_code(relevant_file, relevant_lines_start, existing_code) + d = {**d, "existing_code": existing_code, "improved_code": new_code_snippet} requires_pr_fallback = False if d.get('_is_truncated'): @@ -1126,20 +1294,26 @@ async def push_inline_code_suggestions(self, data, include_coverage_footer: bool elif requires_pr_fallback: body += f"\n\nNot offered as a committable change because {fallback_reason}." - # Keep safety-rejected suggestions out of provider patch APIs while preserving standalone artifacts. + rendered_suggestion = {'body': body, 'relevant_file': relevant_file, + 'relevant_lines_start': relevant_lines_start, + 'relevant_lines_end': relevant_lines_end, + 'original_suggestion': d} + if supports_suggestions_artifact: + artifact_suggestions.append(rendered_suggestion) + + # Keep safety-rejected suggestions out of provider patch APIs and retain fallback recovery text. if not has_valid_anchor or (requires_pr_fallback and not supports_suggestions_artifact): fallback_comments.append(f"{body}\n\nLocation: `{relevant_file}:" f"{relevant_lines_start}-{relevant_lines_end}`") else: - code_suggestions.append({'body': body, 'relevant_file': relevant_file, - 'relevant_lines_start': relevant_lines_start, - 'relevant_lines_end': relevant_lines_end, - 'original_suggestion': d}) + code_suggestions.append(rendered_suggestion) - if code_suggestions: + suggestions_to_publish = artifact_suggestions if supports_suggestions_artifact else code_suggestions + if suggestions_to_publish: if supports_suggestions_artifact: is_successful = self.git_provider.publish_code_suggestions_artifact( - code_suggestions, artifact_footer=coverage_footer) + suggestions_to_publish, artifact_footer=coverage_footer) + artifact_batch_published = is_successful else: is_successful = self.git_provider.publish_code_suggestions(code_suggestions) if is_successful: @@ -1152,7 +1326,7 @@ async def push_inline_code_suggestions(self, data, include_coverage_footer: bool self._output_published = True if coverage_footer and not supports_suggestions_artifact: fallback_comments.append(coverage_footer.strip()) - if fallback_comments: + if fallback_comments and not artifact_batch_published: self.git_provider.publish_comment("\n\n---\n\n".join(fallback_comments)) self._output_published = True if code_suggestions and not is_successful: @@ -1228,11 +1402,10 @@ def _get_patch_range_lines(patch, relevant_lines_start, relevant_lines_end) -> O target_line += 1 target_remaining -= 1 - if all(line_number in target_lines - for line_number in range(relevant_lines_start, relevant_lines_end + 1)): - return [target_lines[line_number] - for line_number in range(relevant_lines_start, relevant_lines_end + 1)] - return None + if len(target_lines) != relevant_lines_end - relevant_lines_start + 1: + return None + return [target_lines[line_number] + for line_number in range(relevant_lines_start, relevant_lines_end + 1)] def _validate_suggestion(self, relevant_file, relevant_lines_start, relevant_lines_end, existing_code) -> tuple[bool, str, bool]: @@ -1507,35 +1680,198 @@ def remove_line_numbers(self, patches_diff_list: List[str]) -> List[str]: get_logger().error(f"Error removing line numbers from patches_diff_list, error: {e}") return patches_diff_list + async def _predict_chunks(self, model: str, chunk_pairs: list) -> list: + if get_settings().pr_code_suggestions.parallel_calls: + results = await asyncio.gather( + *[self._get_prediction(model, numbered, unnumbered) for numbered, unnumbered in chunk_pairs], + return_exceptions=True, + ) + else: + results = [] + for numbered, unnumbered in chunk_pairs: + try: + results.append(await self._get_prediction(model, numbered, unnumbered)) + except Exception as error: + results.append(error) + for result in results: + if isinstance(result, BaseException) and not isinstance(result, Exception): + raise result + return results + + def _recovery_chain(self, model: str, settings) -> Optional[tuple]: + """Return the invocation-local fallback chain and current position after routing. + + The outer retry_with_fallback_models wrapper replaces the configured primary + pair with the routed one and records it as primary, so recovery must reproduce + that substitution; otherwise a routed run would search the configured chain + for a pair that is not there. Returns None when the caller's pair cannot be + found uniquely or a later pair repeats, in which case recovery gives up rather + than guess the position or retry an identical fallback. + """ + effective_chain = get_effective_fallback_chain() + if effective_chain is None: + get_logger().warning("Skipping chunk recovery: no active fallback invocation chain") + return None + original_deployment = settings.get("openai.deployment_id", None) + positions = [index for index, pair in enumerate(effective_chain) + if pair == (model, original_deployment)] + if len(positions) != 1: + get_logger().warning("Skipping chunk recovery: current model/deployment is not unique in the fallback chain") + return None + if len(set(effective_chain)) != len(effective_chain): + get_logger().warning("Skipping chunk recovery: the fallback chain repeats a model/deployment pair") + return None + return effective_chain, positions[0] + 1 + + async def _recover_failed_chunks(self, model: str, chunk_pairs: list, results: list) -> None: + """Try remaining models for failed slots, without replacing successful predictions.""" + # An entirely failed batch still belongs to the existing outer fallback loop. + if not any(isinstance(result, Exception) for result in results) or all( + isinstance(result, Exception) for result in results + ): + return + + settings = get_settings() + chain = self._recovery_chain(model, settings) + if chain is None: + return + fallback_chain, start = chain + original_deployment = settings.get("openai.deployment_id", None) + try: + for fallback_model, deployment in fallback_chain[start:]: + pending = [index for index, result in enumerate(results) if isinstance(result, Exception)] + if not pending: + break + # Keep the original diff intact; truncation must not imply complete coverage. + eligible = [] + recovered_any = False + # Resolve token controls under the same deployment that the fallback request will use. + settings.set("openai.deployment_id", deployment) + try: + attempt_variables = copy.deepcopy(self.vars) + attempt_variables["diff"] = "" + attempt_variables["diff_no_line_numbers"] = "" + attempt_budget = AttemptTokenBudget.for_prompt_attempt( + fallback_model, + getattr(self.git_provider, "pr", None), + attempt_variables, + self.pr_code_suggestions_prompt_system, + self.pr_code_suggestions_prompt_user, + ai_handler=self.ai_handler, + output_token_reserve=getattr( + self.ai_handler, + "get_output_token_reserve", + None, + ), + ) + for index in pending: + numbered, unnumbered = chunk_pairs[index] + + def render(candidate: str) -> tuple[str, str]: + variables = copy.deepcopy(self.vars) + variables["diff"] = numbered + variables["diff_no_line_numbers"] = candidate + return attempt_budget.render_prompt_templates(variables) + + try: + fitted = attempt_budget.fit_optional_text( + unnumbered, + render, + ai_handler=self.ai_handler, + default_output_tokens=OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, + preserve_minimum=True, + ) + except ValueError: + get_logger().warning( + f"Skipping recovery of chunk {index + 1} with {fallback_model}: " + "the required prompt exceeds its token budget" + ) + else: + if fitted.optional_text != unnumbered: + get_logger().warning( + f"Skipping recovery of chunk {index + 1} with {fallback_model}: " + "the complete diff does not fit its token budget" + ) + continue + eligible.append(index) + except Exception as error: + get_logger().warning(f"Cannot prepare chunk recovery with {fallback_model}: {error}") + continue + if not eligible: + continue + # Sibling calls have finished before the deployment switch above. + recovered = await self._predict_chunks(fallback_model, [chunk_pairs[index] for index in eligible]) + for index, result in zip(eligible, recovered, strict=True): + if isinstance(result, Exception): + get_logger().warning( + f"Failed to recover suggestion chunk {index + 1} with {fallback_model}", + artifact={"error": result}, + ) + continue + recovered_any = True + get_logger().info(f"Recovered suggestion chunk {index + 1} with {fallback_model}", + artifact={"error": results[index]}) + results[index] = result + if recovered_any: + # run_details' model line reports the outer primary unless a fallback + # ran, so mark this fallback usage stickily before it is overwritten. + record_model_used(fallback_model, is_fallback=True) + finally: + settings.set("openai.deployment_id", original_deployment) + async def prepare_prediction_main(self, model: str) -> dict: self.failed_chunk_count = 0 self.total_chunk_count = 0 self.parse_failure_count = 0 + output_token_reserve = getattr(self.ai_handler, "get_output_token_reserve", None) + attempt_variables = copy.deepcopy(self.vars) + attempt_variables["diff"] = "" + attempt_variables["diff_no_line_numbers"] = "" + self._suggestion_attempt_budget = AttemptTokenBudget.for_prompt_attempt( + model, + getattr(self.git_provider, "pr", None), + attempt_variables, + self.pr_code_suggestions_prompt_system, + self.pr_code_suggestions_prompt_user, + ai_handler=self.ai_handler, + output_token_reserve=output_token_reserve, + ) + self._suggestion_attempt_budget.require_input_capacity( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + preserve_minimum=True, + ) + attempt_token_handler = self._suggestion_attempt_budget.token_handler # get PR diff if get_settings().pr_code_suggestions.decouple_hunks: self.patches_diff_list = get_pr_multi_diffs(self.git_provider, - self.token_handler, + attempt_token_handler, model, max_calls=get_settings().pr_code_suggestions.max_number_of_calls, - add_line_numbers=True) # decouple hunk with line numbers + add_line_numbers=True, + output_token_reserve=output_token_reserve) # decouple hunk with line numbers self.patches_diff_list_no_line_numbers = self.remove_line_numbers(self.patches_diff_list) # decouple hunk else: # non-decoupled hunks self.patches_diff_list_no_line_numbers = get_pr_multi_diffs(self.git_provider, - self.token_handler, + attempt_token_handler, model, max_calls=get_settings().pr_code_suggestions.max_number_of_calls, - add_line_numbers=False) + add_line_numbers=False, + output_token_reserve=output_token_reserve) self.patches_diff_list = await self.convert_to_decoupled_with_line_numbers( - self.patches_diff_list_no_line_numbers, model) + self.patches_diff_list_no_line_numbers, + model, + attempt_budget=self._suggestion_attempt_budget, + ) if not self.patches_diff_list: # fallback to decoupled hunks self.patches_diff_list = get_pr_multi_diffs(self.git_provider, - self.token_handler, + attempt_token_handler, model, max_calls=get_settings().pr_code_suggestions.max_number_of_calls, - add_line_numbers=True) # decouple hunk with line numbers + add_line_numbers=True, + output_token_reserve=output_token_reserve) # decouple hunk with line numbers self.patches_diff_list_no_line_numbers = self.remove_line_numbers(self.patches_diff_list) if self.patches_diff_list: @@ -1548,38 +1884,17 @@ async def prepare_prediction_main(self, model: str) -> dict: zip(self.patches_diff_list, self.patches_diff_list_no_line_numbers, strict=True)) self.total_chunk_count = len(chunk_pairs) - # parallelize calls to AI: - if get_settings().pr_code_suggestions.parallel_calls: - prediction_results = await asyncio.gather( - *[self._get_prediction(model, patches_diff, patches_diff_no_line_numbers) for - patches_diff, patches_diff_no_line_numbers in chunk_pairs], - return_exceptions=True) - for chunk_index, prediction in enumerate(prediction_results): - if isinstance(prediction, Exception): - chunk_errors.append(prediction) - get_logger().warning( - f"Failed to generate code suggestions for chunk {chunk_index + 1}; " - "retaining successful chunks", - artifact={"error": prediction}, - ) - elif isinstance(prediction, BaseException): - raise prediction - else: - prediction_list.append(prediction) - else: - for chunk_index, (patches_diff, patches_diff_no_line_numbers) in enumerate( - chunk_pairs): - try: - prediction = await self._get_prediction(model, patches_diff, patches_diff_no_line_numbers) - except Exception as e: - chunk_errors.append(e) - get_logger().warning( - f"Failed to generate code suggestions for chunk {chunk_index + 1}; " - "retaining successful chunks", - artifact={"error": e}, - ) - else: - prediction_list.append(prediction) + prediction_results = await self._predict_chunks(model, chunk_pairs) + await self._recover_failed_chunks(model, chunk_pairs, prediction_results) + for chunk_index, prediction in enumerate(prediction_results): + if isinstance(prediction, Exception): + chunk_errors.append(prediction) + get_logger().warning( + f"Failed to generate code suggestions for chunk {chunk_index + 1}; retaining successful chunks", + artifact={"error": prediction}, + ) + else: + prediction_list.append(prediction) self.failed_chunk_count = len(chunk_errors) + self.parse_failure_count if chunk_errors and not prediction_list: @@ -1606,12 +1921,30 @@ async def prepare_prediction_main(self, model: str) -> dict: self.data = data else: get_logger().warning("Empty PR diff list") - self.data = data = None + raise ValueError(f"No PR diff fits the /improve request for {model}") return data - async def convert_to_decoupled_with_line_numbers(self, patches_diff_list_no_line_numbers, model) -> List[str]: + async def convert_to_decoupled_with_line_numbers( + self, + patches_diff_list_no_line_numbers, + model, + *, + attempt_budget: AttemptTokenBudget | None = None, + ) -> List[str]: with get_logger().contextualize(sub_feature='convert_to_decoupled_with_line_numbers'): try: + if attempt_budget is None: + attempt_budget = AttemptTokenBudget.for_attempt( + model, + self.token_handler, + output_token_reserve=getattr( + getattr(self, "ai_handler", None), "get_output_token_reserve", None + ), + ignore_max_model_tokens=True, + ) + max_input_tokens = attempt_budget.available_tokens( + 2_000, preserve_minimum=True + ) patches_diff_list = [] for patch_prompt in patches_diff_list_no_line_numbers: file_prefix = "## File: " @@ -1628,20 +1961,26 @@ async def convert_to_decoupled_with_line_numbers(self, patches_diff_list_no_line file=None).strip() patches_new[i] = patches_new[i].strip() patch_final = "\n\n\n".join(patches_new) - if model in MAX_TOKENS: - max_tokens_full = MAX_TOKENS[ - model] # note - here we take the actual max tokens, without any reductions. we do aim to get the full documentation website in the prompt - else: - max_tokens_full = get_max_tokens(model) - delta_output = 2000 - token_count = self.token_handler.count_tokens(patch_final) - if token_count > max_tokens_full - delta_output: + token_count = attempt_budget.count_tokens(patch_final) + if token_count > max_input_tokens: get_logger().warning( - f"Token count {token_count} exceeds the limit {max_tokens_full - delta_output}. clipping the tokens") - patch_final = clip_tokens(patch_final, max_tokens_full - delta_output) + f"Token count {token_count} exceeds the limit {max_input_tokens}. clipping the tokens") + add_truncation_marker = True + while patch_final and token_count > max_input_tokens: + clipped_patch = clip_tokens( + patch_final, + max_input_tokens, + add_three_dots=add_truncation_marker, + num_input_tokens=token_count, + ) + add_truncation_marker = False + if len(clipped_patch) >= len(patch_final): + clipped_patch = patch_final[:len(patch_final) // 2] + patch_final = clipped_patch + token_count = attempt_budget.count_tokens(patch_final) patches_diff_list.append(patch_final) return patches_diff_list - except Exception as e: + except Exception: get_logger().exception("Error converting to decoupled with line numbers", artifact={'patches_diff_list_no_line_numbers': patches_diff_list_no_line_numbers}) return [] @@ -1672,11 +2011,21 @@ def generate_summarized_suggestions(self, data: Dict) -> str: suggestions_labels = dict() # add all suggestions related to each label for suggestion in data['code_suggestions']: + if not self._is_suggestion_line_range_valid(suggestion): + # suggestions without resolved line anchors (e.g. when self-reflection + # failed or returned a mismatched count) cannot be placed in the diff; + # skip them instead of failing the whole table + continue label = suggestion['label'].strip().strip("'").strip('"') if label not in suggestions_labels: suggestions_labels[label] = [] suggestions_labels[label].append(suggestion) + if not suggestions_labels: + pr_body = f"{format_pr_code_suggestions_header()}\n\n" + pr_body += "No suggestions found to improve this PR." + return pr_body + # sort suggestions_labels by the suggestion with the highest score suggestions_labels = dict( sorted(suggestions_labels.items(), key=lambda x: max([s['score'] for s in x[1]]), reverse=True)) @@ -1808,18 +2157,40 @@ async def self_reflect_on_suggestions(self, include_ai_metadata=is_ai_metadata, ), 'duplicate_prompt_examples': get_settings().config.get('duplicate_prompt_examples', False)} - environment = Environment(undefined=StrictUndefined) - if dedicated_prompt: - system_prompt_reflect = environment.from_string( - get_settings().get(dedicated_prompt).system).render(variables) - user_prompt_reflect = environment.from_string( - get_settings().get(dedicated_prompt).user).render(variables) + system_template = get_settings().get(dedicated_prompt).system + user_template = get_settings().get(dedicated_prompt).user else: - system_prompt_reflect = environment.from_string( - get_settings().pr_code_suggestions_reflect_prompt.system).render(variables) - user_prompt_reflect = environment.from_string( - get_settings().pr_code_suggestions_reflect_prompt.user).render(variables) + system_template = get_settings().pr_code_suggestions_reflect_prompt.system + user_template = get_settings().pr_code_suggestions_reflect_prompt.user + + raw_diff = variables["diff"] + variables["diff"] = "" + output_token_reserve = getattr(self.ai_handler, "get_output_token_reserve", None) + git_provider = getattr(self, "git_provider", None) + budget = AttemptTokenBudget.for_prompt_attempt( + model, + getattr(git_provider, "pr", None), + variables, + system_template, + user_template, + ai_handler=self.ai_handler, + output_token_reserve=output_token_reserve, + ) + fitted = budget.fit_prompt_variable( + variables, + "diff", + raw_diff, + ai_handler=self.ai_handler, + default_output_tokens=OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + keep="prefix", + ) + if fitted.optional_text != raw_diff: + raise ValueError( + f"The complete reflection diff does not fit the token limit for {model}" + ) + system_prompt_reflect = fitted.system_prompt + user_prompt_reflect = fitted.user_prompt with get_logger().contextualize(command="self_reflect_on_suggestions"): response_reflect, finish_reason_reflect = await self.ai_handler.chat_completion(model=model, diff --git a/pr_agent/tools/pr_config.py b/pr_agent/tools/pr_config.py index 293db20e95..a4080f4643 100644 --- a/pr_agent/tools/pr_config.py +++ b/pr_agent/tools/pr_config.py @@ -15,7 +15,8 @@ def __init__(self, pr_url: str, args=None, ai_handler=None): Args: pr_url (str): The URL of the pull request to be reviewed. - args (list, optional): List of arguments passed to the PRReviewer class. Defaults to None. + args (list, optional): Unused compatibility parameter for the common tool interface. Defaults to None. + ai_handler: Unused compatibility parameter for the common tool interface. Defaults to None. """ self.git_provider = get_git_provider()(pr_url) diff --git a/pr_agent/tools/pr_description.py b/pr_agent/tools/pr_description.py index b1dc807a9e..629c4e13a4 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -7,10 +7,10 @@ from typing import List, Tuple import yaml -from jinja2 import Environment, StrictUndefined from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler +from pr_agent.algo.comment_identity import PRDescriptionHeader from pr_agent.algo.pr_processing import ( OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, get_pr_diff, @@ -18,23 +18,19 @@ retry_with_fallback_models, ) from pr_agent.algo.repo_context import build_repo_context -from pr_agent.algo.run_details import init_run_details +from pr_agent.algo.run_details import init_run_details, record_command_failure +from pr_agent.algo.run_output import push_outputs, show_relevant_configurations, show_run_details from pr_agent.algo.skills_loader import get_skills_context +from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.utils import ( ModelType, - PRDescriptionHeader, - clip_tokens, - get_max_tokens, get_user_labels, load_yaml, - push_outputs, set_custom_labels, - show_relevant_configurations, - show_run_details, ) from pr_agent.config_loader import get_settings -from pr_agent.git_providers import GithubProvider, get_git_provider_with_context +from pr_agent.git_providers import get_git_provider_with_context from pr_agent.git_providers.git_provider import get_main_pr_language from pr_agent.log import get_logger from pr_agent.servers.help import HelpMessage @@ -43,6 +39,8 @@ fit_related_tickets_to_prompt_budget, ) +MAX_DESCRIPTION_COVERAGE_FILES = 50 + class PRDescription: def __init__(self, pr_url: str, args: list = None, @@ -88,6 +86,7 @@ def __init__(self, pr_url: str, args: list = None, "custom_labels_class": "", # will be filled if necessary in 'set_custom_labels' function "enable_semantic_files_types": get_settings().pr_description.enable_semantic_files_types, "related_tickets": "", + "related_tickets_omitted": 0, "include_file_summary_changes": len(self.git_provider.get_diff_files()) <= self.COLLAPSIBLE_FILE_LIST_THRESHOLD, "duplicate_prompt_examples": get_settings().config.get("duplicate_prompt_examples", False), "enable_pr_diagram": enable_pr_diagram, @@ -136,19 +135,18 @@ async def run(self): if get_settings().pr_description.enable_semantic_files_types: self.file_label_dict = self._prepare_file_labels() - pr_labels, pr_file_changes = [], [] + pr_labels = [] if get_settings().pr_description.publish_labels: pr_labels = self._prepare_labels() else: get_logger().debug("Publishing labels disabled") if get_settings().pr_description.use_description_markers: - pr_title, pr_body, changes_walkthrough, pr_file_changes = self._prepare_pr_answer_with_markers() + pr_title, pr_body = self._prepare_pr_answer_with_markers() else: - pr_title, pr_body, changes_walkthrough, pr_file_changes = self._prepare_pr_answer() - if not self.git_provider.is_supported( - "publish_file_comments") or not get_settings().pr_description.inline_file_summary: - pr_body += "\n\n" + changes_walkthrough + "___\n\n" + pr_title, pr_body, changes_walkthrough = self._prepare_pr_answer() + pr_body += "\n\n" + changes_walkthrough + "___\n\n" + pr_body += self._get_description_coverage_footer() get_logger().debug("PR output", artifact={"title": pr_title, "body": pr_body}) # Add help text if gfm_markdown is supported @@ -157,15 +155,15 @@ async def run(self): pr_body += HelpMessage.get_describe_usage_guide() pr_body += "\n\n" elif get_settings().pr_description.enable_help_comment and self.git_provider.is_supported("gfm_markdown"): - if isinstance(self.git_provider, GithubProvider): + if self.git_provider.supports_inline_help_footer(): pr_body += ('\n\n___\n\n>
Need help?
  • Type /help how to ... ' 'in the comments thread for any questions about PR-Agent usage.
  • Check out the ' - 'documentation ' + 'documentation ' 'for more information.
  • ') - else: # gitlab + else: # bullets separated by
    , for providers whose footer cannot inline a list pr_body += ("\n\n___\n\n
    Need help?- Type /help how to ... in the comments " "thread for any questions about PR-Agent usage.
    - Check out the " - "documentation for more information.
    ") + "documentation for more information.") # elif get_settings().pr_description.enable_help_comment: # pr_body += '\n\n___\n\n> 💡 **PR-Agent usage**: Comment `/help "your question"` on any pull request to receive relevant information' @@ -239,6 +237,8 @@ async def run(self): except Exception as e: get_logger().error(f"Error generating PR description {self.pr_id}: {e}", artifact={"traceback": traceback.format_exc()}) + # The status of the whole run must not read as success just because the error stopped here. + record_command_failure() if get_settings().config.get("propagate_tool_errors", False): raise finally: @@ -259,21 +259,44 @@ async def run(self): return "" async def _prepare_prediction(self, model: str) -> None: + self.description_total_chunk_count = 0 + self.description_failed_chunk_count = 0 + self.description_failed_files = [] if get_settings().pr_description.use_description_markers and 'pr_agent:' not in self.user_description: get_logger().info("Markers were enabled, but user description does not contain markers. Skipping AI prediction") return None raw_prompt_vars = getattr(self, "_raw_prompt_vars", getattr(self, "vars", None)) + ai_handler = getattr(self, "ai_handler", None) + output_token_reserve = getattr( + ai_handler, "get_output_token_reserve", None + ) + self._description_prompt_handlers = {} if raw_prompt_vars is not None: + attempt_raw_vars = copy.deepcopy(raw_prompt_vars) + set_custom_labels(attempt_raw_vars, self.git_provider) self.vars, self.token_handler = fit_related_tickets_to_prompt_budget( self.git_provider.pr, - raw_prompt_vars, + attempt_raw_vars, get_settings().pr_description_prompt.system, get_settings().pr_description_prompt.user, model, + ai_handler=ai_handler, + output_token_reserve=output_token_reserve, ) + self._description_prompt_handlers["pr_description_prompt"] = self.token_handler large_pr_handling = get_settings().pr_description.get("enable_large_pr_handling", True) and "pr_description_only_files_prompts" in get_settings() - output = get_pr_diff(self.git_provider, self.token_handler, model, large_pr_handling=large_pr_handling, return_remaining_files=True) + output_token_reserve_kwargs = ( + {"output_token_reserve": output_token_reserve} if callable(output_token_reserve) else {} + ) + output = get_pr_diff( + self.git_provider, + self.token_handler, + model, + large_pr_handling=large_pr_handling, + return_remaining_files=True, + **output_token_reserve_kwargs, + ) if isinstance(output, tuple): patches_diff, remaining_files_list = output else: @@ -285,7 +308,11 @@ async def _prepare_prediction(self, model: str) -> None: if patches_diff: # generate the prediction get_logger().debug("PR diff", artifact=self.patches_diff) - self.prediction = await self._get_prediction(model, patches_diff, prompt="pr_description_prompt") + self.prediction = await self._get_prediction( + model, + patches_diff, + prompt="pr_description_prompt", + ) # extend the prediction with additional files not shown if get_settings().pr_description.enable_semantic_files_types: @@ -293,57 +320,136 @@ async def _prepare_prediction(self, model: str) -> None: else: get_logger().error(f"Error getting PR diff {self.pr_id}", artifact={"traceback": traceback.format_exc()}) - self.prediction = None + raise ValueError( + f"No PR diff fits the /describe request for {model}" + ) else: # get the diff in multiple patches, with the token handler only for the files prompt get_logger().debug('large_pr_handling for describe') self.vars, token_handler_only_files_prompt = fit_related_tickets_to_prompt_budget( self.git_provider.pr, - raw_prompt_vars if raw_prompt_vars is not None else self.vars, + attempt_raw_vars if raw_prompt_vars is not None else self.vars, get_settings().pr_description_only_files_prompts.system, get_settings().pr_description_only_files_prompts.user, model, + ai_handler=ai_handler, + output_token_reserve=output_token_reserve, + ) + self._description_prompt_handlers["pr_description_only_files_prompts"] = ( + token_handler_only_files_prompt ) (patches_compressed_list, total_tokens_list, deleted_files_list, remaining_files_list, file_dict, files_in_patches_list) = get_pr_diff_multiple_patchs( - self.git_provider, token_handler_only_files_prompt, model) + self.git_provider, + token_handler_only_files_prompt, + model, + **output_token_reserve_kwargs, + ) # get the files prediction for each patch + chunk_pairs = list(zip(patches_compressed_list, files_in_patches_list, strict=True)) + self.description_total_chunk_count = len(chunk_pairs) + results = [None] * len(chunk_pairs) if not get_settings().pr_description.get("async_ai_calls", True): - results = [] - for i, patches in enumerate(patches_compressed_list): # sync calls + for i, (patches, _files_in_patch) in enumerate(chunk_pairs): # sync calls + if not patches: + continue patches_diff = "\n".join(patches) get_logger().debug(f"PR diff number {i + 1} for describe files") - prediction_files = await self._get_prediction(model, patches_diff, - prompt="pr_description_only_files_prompts") - results.append(prediction_files) + try: + results[i] = await self._get_prediction( + model, + patches_diff, + prompt="pr_description_only_files_prompts", + ) + except Exception as e: + results[i] = e else: # async calls tasks = [] - for i, patches in enumerate(patches_compressed_list): + task_indices = [] + for i, (patches, _files_in_patch) in enumerate(chunk_pairs): if patches: patches_diff = "\n".join(patches) get_logger().debug(f"PR diff number {i + 1} for describe files") task = asyncio.create_task( - self._get_prediction(model, patches_diff, prompt="pr_description_only_files_prompts")) + self._get_prediction( + model, + patches_diff, + prompt="pr_description_only_files_prompts", + ) + ) tasks.append(task) + task_indices.append(i) # Wait for all tasks to complete - results = await asyncio.gather(*tasks) + task_results = await asyncio.gather(*tasks, return_exceptions=True) + for chunk_index, result in zip(task_indices, task_results, strict=True): + results[chunk_index] = result file_description_str_list = [] - for i, result in enumerate(results): + chunk_errors = [] + failed_files = [] + for i, (result, (_patches, files_in_patch)) in enumerate(zip(results, chunk_pairs, strict=True)): + if isinstance(result, Exception): + chunk_errors.append(result) + failed_files.extend(files_in_patch) + get_logger().warning( + f"Failed to generate description for chunk {i + 1}; retaining successful chunks", + artifact={"error": result, "files": files_in_patch}, + ) + continue + if isinstance(result, BaseException): + raise result + if not isinstance(result, str): + chunk_errors.append(ValueError(f"Description chunk {i + 1} returned no prediction")) + failed_files.extend(files_in_patch) + get_logger().warning( + f"Description chunk {i + 1} returned no prediction; retaining successful chunks", + artifact={"files": files_in_patch}, + ) + continue prediction_files = result.strip().removeprefix('```yaml').strip('`').strip() - if load_yaml(prediction_files, keys_fix_yaml=self.keys_fix) and prediction_files.startswith('pr_files'): + prediction_files_data = load_yaml(prediction_files, keys_fix_yaml=self.keys_fix) + file_descriptions = (prediction_files_data.get('pr_files') + if isinstance(prediction_files_data, dict) else None) + required_fields = ['filename', 'changes_title', 'label'] + if self.vars.get('include_file_summary_changes', True): + required_fields.append('changes_summary') + valid_file_descriptions = ( + isinstance(file_descriptions, list) and file_descriptions and + any(isinstance(file_description, dict) and + all(isinstance(file_description.get(field), str) and file_description[field].strip() + for field in required_fields) + for file_description in file_descriptions) + ) + if (prediction_files.startswith('pr_files') and isinstance(prediction_files_data, dict) and + valid_file_descriptions): prediction_files = prediction_files.removeprefix('pr_files:').strip() file_description_str_list.append(prediction_files) else: - get_logger().debug(f"failed to generate predictions in iteration {i + 1} for describe files") + chunk_errors.append(ValueError(f"Description chunk {i + 1} returned invalid YAML")) + failed_files.extend(files_in_patch) + get_logger().warning( + f"Failed to parse description chunk {i + 1}; retaining successful chunks", + artifact={"files": files_in_patch}, + ) + + self.description_failed_chunk_count = len(chunk_pairs) - len(file_description_str_list) + self.description_failed_files = list(dict.fromkeys(failed_files)) + if not file_description_str_list: + raise chunk_errors[0] if chunk_errors else ValueError("No description chunks were generated") # generate files_walkthrough string, with proper token handling self.vars, token_handler_only_description_prompt = fit_related_tickets_to_prompt_budget( self.git_provider.pr, - raw_prompt_vars if raw_prompt_vars is not None else self.vars, + attempt_raw_vars if raw_prompt_vars is not None else self.vars, get_settings().pr_description_only_description_prompts.system, get_settings().pr_description_only_description_prompts.user, - model) + model, + ai_handler=ai_handler, + output_token_reserve=output_token_reserve, + ) + self._description_prompt_handlers["pr_description_only_description_prompts"] = ( + token_handler_only_description_prompt + ) files_walkthrough = "\n".join(file_description_str_list) files_walkthrough_prompt = copy.deepcopy(files_walkthrough) MAX_EXTRA_FILES_TO_PROMPT = 50 @@ -363,16 +469,6 @@ async def _prepare_prediction(self, model: str) -> None: get_logger().debug(f"Too many deleted files, clipping to {MAX_EXTRA_FILES_TO_PROMPT}") files_walkthrough_prompt += f"\n... and {len(deleted_files_list) - MAX_EXTRA_FILES_TO_PROMPT} more" break - tokens_files_walkthrough = len( - token_handler_only_description_prompt.encoder.encode(files_walkthrough_prompt)) - total_tokens = token_handler_only_description_prompt.prompt_tokens + tokens_files_walkthrough - max_tokens_model = get_max_tokens(model) - if total_tokens > max_tokens_model - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD: - # clip files_walkthrough to git the tokens within the limit - files_walkthrough_prompt = clip_tokens(files_walkthrough_prompt, - max_tokens_model - OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD - token_handler_only_description_prompt.prompt_tokens, - num_input_tokens=tokens_files_walkthrough) - # PR header inference get_logger().debug("PR diff only description", artifact=files_walkthrough_prompt) prediction_headers = await self._get_prediction(model, patches_diff=files_walkthrough_prompt, @@ -390,6 +486,27 @@ async def _prepare_prediction(self, model: str) -> None: get_logger().debug(f"Using only headers for describe {self.pr_id}") self.prediction = prediction_headers + def _get_description_coverage_footer(self) -> str: + failed_chunk_count = getattr(self, "description_failed_chunk_count", 0) + if not failed_chunk_count: + return "" + + total_chunk_count = getattr(self, "description_total_chunk_count", failed_chunk_count) + footer = ( + f"\n\n⚠️ **Description coverage:** {failed_chunk_count} of {total_chunk_count} " + "file-description chunks failed; the description above is based on the successful chunks only." + ) + failed_files = getattr(self, "description_failed_files", []) + if failed_files: + displayed_files = failed_files[:MAX_DESCRIPTION_COVERAGE_FILES] + footer += "\n\nFiles from failed chunks may not be covered:\n" + "\n".join( + f"- `{filename}`" for filename in displayed_files + ) + remaining_count = len(failed_files) - len(displayed_files) + if remaining_count: + footer += f"\n... and {remaining_count} more" + return footer + async def extend_uncovered_files(self, original_prediction: str) -> str: try: prediction = original_prediction @@ -462,46 +579,49 @@ async def extend_uncovered_files(self, original_prediction: str) -> str: return original_prediction - async def extend_additional_files(self, remaining_files_list) -> str: - prediction = self.prediction - try: - original_prediction_dict = load_yaml(self.prediction, keys_fix_yaml=self.keys_fix) - prediction_extra = "pr_files:" - for file in remaining_files_list: - extra_file_yaml = f"""\ -- filename: | - {file} - changes_summary: | - ... - changes_title: | - ... - label: | - additional files (token-limit) -""" - prediction_extra = prediction_extra + "\n" + extra_file_yaml.strip() - prediction_extra_dict = load_yaml(prediction_extra, keys_fix_yaml=self.keys_fix) - # merge the two dictionaries - if isinstance(original_prediction_dict, dict) and isinstance(prediction_extra_dict, dict): - original_prediction_dict["pr_files"].extend(prediction_extra_dict["pr_files"]) - new_yaml = yaml.dump(original_prediction_dict) - if load_yaml(new_yaml, keys_fix_yaml=self.keys_fix): - prediction = new_yaml - return prediction - except Exception as e: - get_logger().error(f"Error extending additional files {self.pr_id}: {e}") - return self.prediction - - async def _get_prediction(self, model: str, patches_diff: str, prompt="pr_description_prompt") -> str: + async def _get_prediction( + self, + model: str, + patches_diff: str, + prompt="pr_description_prompt", + ) -> str: variables = copy.deepcopy(self.vars) - variables["diff"] = patches_diff # update diff - - environment = Environment(undefined=StrictUndefined) - set_custom_labels(variables, self.git_provider) + output_token_reserve = getattr(self.ai_handler, "get_output_token_reserve", None) + token_handler = getattr(self, "_description_prompt_handlers", {}).get(prompt) + if token_handler is None: + variables["diff"] = "" + budget = AttemptTokenBudget.for_prompt_attempt( + model, + getattr(self.git_provider, "pr", None), + variables, + get_settings().get(prompt, {}).get("system", ""), + get_settings().get(prompt, {}).get("user", ""), + ai_handler=self.ai_handler, + output_token_reserve=output_token_reserve, + ) + else: + budget = AttemptTokenBudget.for_attempt( + model, + token_handler, + output_token_reserve=output_token_reserve, + ) + fitted = budget.fit_prompt_variable( + variables, + "diff", + patches_diff, + ai_handler=self.ai_handler, + default_output_tokens=OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, + preserve_minimum=True, + ) + if prompt != "pr_description_only_description_prompts" and fitted.optional_text != patches_diff: + raise ValueError( + f"The complete packed description diff does not fit the token limit for {model}" + ) + variables["diff"] = fitted.optional_text + system_prompt = fitted.system_prompt + user_prompt = fitted.user_prompt self.variables = variables - system_prompt = environment.from_string(get_settings().get(prompt, {}).get("system", "")).render(self.variables) - user_prompt = environment.from_string(get_settings().get(prompt, {}).get("user", "")).render(self.variables) - response, finish_reason = await self.ai_handler.chat_completion( model=model, temperature=get_settings().config.temperature, @@ -568,7 +688,7 @@ def _prepare_labels(self) -> List[str]: get_logger().error(f"Error converting labels to original case {self.pr_id}: {e}") return pr_labels - def _prepare_pr_answer_with_markers(self) -> Tuple[str, str, str, List[dict]]: + def _prepare_pr_answer_with_markers(self) -> Tuple[str, str]: get_logger().info(f"Using description marker replacements {self.pr_id}") # Remove the 'PR Title' key from the dictionary @@ -609,11 +729,9 @@ def _prepare_pr_answer_with_markers(self) -> Tuple[str, str, str, List[dict]]: ai_walkthrough = self.data.get('pr_files') walkthrough_gfm = "" - pr_file_changes = [] if ai_walkthrough and not re.search(r'', body): try: - walkthrough_gfm, pr_file_changes = self.process_pr_files_prediction(walkthrough_gfm, - self.file_label_dict) + walkthrough_gfm = self.process_pr_files_prediction(walkthrough_gfm, self.file_label_dict) body = body.replace('pr_agent:walkthrough', walkthrough_gfm) except Exception as e: get_logger().error(f"Failing to process walkthrough {self.pr_id}: {e}") @@ -622,11 +740,11 @@ def _prepare_pr_answer_with_markers(self) -> Tuple[str, str, str, List[dict]]: # Add support for pr_agent:diagram marker (plain and HTML comment formats) ai_diagram = self.data.get('changes_diagram') if ai_diagram: - body = re.sub(r'|pr_agent:diagram', ai_diagram, body) + body = re.sub(r'|pr_agent:diagram', lambda _match: ai_diagram, body) - return title, body, walkthrough_gfm, pr_file_changes + return title, body - def _prepare_pr_answer(self) -> Tuple[str, str, str, List[dict]]: + def _prepare_pr_answer(self) -> Tuple[str, str, str]: """ Prepare the PR description based on the AI prediction data. @@ -656,7 +774,6 @@ def _prepare_pr_answer(self) -> Tuple[str, str, str, List[dict]]: # Iterate over the remaining dictionary items and append the key and value to 'pr_body' in a markdown format, # except for the items containing the word 'walkthrough' pr_body, changes_walkthrough = "", "" - pr_file_changes = [] for idx, (key, value) in enumerate(self.data.items()): if key == 'changes_diagram': pr_body += f"### {PRDescriptionHeader.DIAGRAM_WALKTHROUGH.value}\n\n" @@ -681,7 +798,7 @@ def _prepare_pr_answer(self) -> Tuple[str, str, str, List[dict]]: if self.git_provider.is_supported("gfm_markdown"): pr_body += "\n" elif 'pr_files' in key.lower() and get_settings().pr_description.enable_semantic_files_types: # 'File Walkthrough' section - changes_walkthrough_table, pr_file_changes = self.process_pr_files_prediction(changes_walkthrough, value) + changes_walkthrough_table = self.process_pr_files_prediction(changes_walkthrough, value) if get_settings().pr_description.get('file_table_collapsible_open_by_default', False): initial_status = " open" else: @@ -702,7 +819,7 @@ def _prepare_pr_answer(self) -> Tuple[str, str, str, List[dict]]: if idx < len(self.data) - 1: pr_body += "\n\n___\n\n" - return title, pr_body, changes_walkthrough, pr_file_changes, + return title, pr_body, changes_walkthrough def _prepare_file_labels(self): file_label_dict = {} @@ -733,13 +850,12 @@ def _prepare_file_labels(self): if label not in file_label_dict: file_label_dict[label] = [] file_label_dict[label].append((filename, changes_title, changes_summary)) - except Exception as e: + except Exception: get_logger().exception(f"Error preparing file label dict {self.pr_id}") pass return file_label_dict def process_pr_files_prediction(self, pr_body, value): - pr_comments = [] # logic for using collapsible file list use_collapsible_file_list = get_settings().pr_description.collapsible_file_list num_files = 0 @@ -750,7 +866,7 @@ def process_pr_files_prediction(self, pr_body, value): use_collapsible_file_list = num_files > self.COLLAPSIBLE_FILE_LIST_THRESHOLD if not self.git_provider.is_supported("gfm_markdown"): - return pr_body, pr_comments + return pr_body try: pr_body += "" header = "Relevant files" @@ -816,7 +932,7 @@ def process_pr_files_prediction(self, pr_body, value): except Exception as e: get_logger().error(f"Error processing pr files to markdown {self.pr_id}: {str(e)}") pass - return pr_body, pr_comments + return pr_body def add_file_data(self, delta_nbsp, diff_plus_minus, file_change_description_br, filename, filename_publish, link, pr_body) -> str: diff --git a/pr_agent/tools/pr_generate_labels.py b/pr_agent/tools/pr_generate_labels.py index cf7a5230d7..7c1aa27b74 100644 --- a/pr_agent/tools/pr_generate_labels.py +++ b/pr_agent/tools/pr_generate_labels.py @@ -2,11 +2,15 @@ from functools import partial from typing import List -from jinja2 import Environment, StrictUndefined - from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler -from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models +from pr_agent.algo.output_models import Labels +from pr_agent.algo.pr_processing import ( + OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, + get_pr_diff, + retry_with_fallback_models, +) +from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.utils import get_user_labels, load_yaml, set_custom_labels from pr_agent.config_loader import get_settings @@ -15,22 +19,6 @@ from pr_agent.log import get_logger -def _label_name(label) -> str: - """Read one label entry, tolerating the mapping form a model may return. - - The prompt asks for a list of strings, but a schema presented as a class invites entries - such as ``- name: bug fix``. An entry with no readable name is dropped. - """ - if isinstance(label, dict): - for key in ("name", "label", "title", "value"): - if isinstance(label.get(key), str) and label[key].strip(): - return label[key].strip() - return "" - if isinstance(label, bool) or not isinstance(label, (str, int, float)): - return "" - return str(label).strip() - - class PRGenerateLabels: def __init__(self, pr_url: str, args: list = None, ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler): @@ -67,32 +55,34 @@ def __init__(self, pr_url: str, args: list = None, # Initialize the token handler self.token_handler = TokenHandler( - self.git_provider.pr, + getattr(self.git_provider, "pr", None), self.vars, get_settings().pr_custom_labels_prompt.system, get_settings().pr_custom_labels_prompt.user, ) - # Initialize patches_diff and prediction attributes + # Initialize prediction state self.patches_diff = None self.prediction = None + self.data = None async def run(self): """ Generates a PR labels using an AI model and publishes it to the PR. """ + progress_comment = None try: get_logger().info(f"Generating a PR labels {self.pr_id}") if get_settings().config.publish_output: - self.git_provider.publish_comment("Preparing PR labels...", is_temporary=True) + progress_comment = self.git_provider.publish_comment( + "Preparing PR labels...", is_temporary=True + ) await retry_with_fallback_models(self._prepare_prediction, git_provider=self.git_provider) get_logger().info(f"Preparing answer {self.pr_id}") - if self.prediction: - self._prepare_data() - else: + if self.prediction is None: return None pr_labels = self._prepare_labels() @@ -100,19 +90,25 @@ async def run(self): if get_settings().config.publish_output: get_logger().info(f"Pushing labels {self.pr_id}") - current_labels = self.git_provider.get_pr_labels() - user_labels = get_user_labels(current_labels) - pr_labels = pr_labels + user_labels - if self.git_provider.is_supported("get_labels"): + current_labels = self.git_provider.get_pr_labels() + user_labels = get_user_labels(current_labels) + pr_labels = pr_labels + user_labels self.git_provider.publish_labels(pr_labels) elif pr_labels: value = ', '.join(v for v in pr_labels) pr_labels_text = f"## PR Labels:\n{value}\n" self.git_provider.publish_comment(pr_labels_text, is_temporary=False) - self.git_provider.remove_initial_comment() except Exception as e: get_logger().error(f"Error generating PR labels {self.pr_id}: {e}") + if get_settings().config.get("propagate_tool_errors", False): + raise + finally: + if progress_comment is not None: + try: + self.git_provider.remove_initial_comment() + except Exception as e: + get_logger().error(f"Failed to remove temporary PR labels comment {self.pr_id}: {e}") return "" @@ -131,10 +127,53 @@ async def _prepare_prediction(self, model: str) -> None: """ + self.prediction = None + self.data = None + get_logger().info(f"Getting PR diff {self.pr_id}") - self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model) + variables = copy.deepcopy(self.vars) + set_custom_labels(variables, self.git_provider) + output_token_reserve = getattr(self.ai_handler, "get_output_token_reserve", None) + budget = AttemptTokenBudget.for_prompt_attempt( + model, + getattr(self.git_provider, "pr", None), + variables, + get_settings().pr_custom_labels_prompt.system, + get_settings().pr_custom_labels_prompt.user, + ai_handler=self.ai_handler, + output_token_reserve=output_token_reserve, + ) + patches_diff = get_pr_diff( + self.git_provider, + budget.token_handler, + model, + output_token_reserve=output_token_reserve, + ) + if not patches_diff: + raise ValueError(f"No PR diff fits the /generate_labels request for {model}") + fitted = budget.fit_prompt_variable( + variables, + "diff", + patches_diff, + ai_handler=self.ai_handler, + default_output_tokens=OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, + preserve_minimum=True, + ) + if fitted.optional_text != patches_diff: + raise ValueError( + f"The complete packed labels diff does not fit the token limit for {model}" + ) + variables["diff"] = fitted.optional_text + self.patches_diff = fitted.optional_text + self._attempt_system_prompt = fitted.system_prompt + self._attempt_user_prompt = fitted.user_prompt get_logger().info(f"Getting AI prediction {self.pr_id}") - self.prediction = await self._get_prediction(model) + prediction = await self._get_prediction(model) + data = self._load_valid_labels_yaml(prediction) + + self.variables = variables + self.prediction = prediction + self.data = data async def _get_prediction(self, model: str) -> str: """ @@ -146,41 +185,26 @@ async def _get_prediction(self, model: str) -> str: Returns: str: The generated AI prediction. """ - variables = copy.deepcopy(self.vars) - variables["diff"] = self.patches_diff # update diff - - environment = Environment(undefined=StrictUndefined) - set_custom_labels(variables, self.git_provider) - self.variables = variables - - system_prompt = environment.from_string(get_settings().pr_custom_labels_prompt.system).render(self.variables) - user_prompt = environment.from_string(get_settings().pr_custom_labels_prompt.user).render(self.variables) - response, finish_reason = await self.ai_handler.chat_completion( model=model, temperature=get_settings().config.temperature, - system=system_prompt, - user=user_prompt + system=self._attempt_system_prompt, + user=self._attempt_user_prompt, ) return response def _prepare_data(self): - # Load the AI prediction data into a dictionary - self.data = load_yaml(self.prediction.strip()) - + self.data = self._load_valid_labels_yaml(self.prediction) + @staticmethod + def _load_valid_labels_yaml(prediction: str) -> dict: + """Load a usable labels response or fail the current model attempt.""" + data = load_yaml(prediction.strip()) + return Labels.model_validate(data).model_dump() def _prepare_labels(self) -> List[str]: - pr_types = [] - - # If the 'labels' key is present in the dictionary, split its value by comma and assign it to 'pr_types' - if "labels" in self.data: - if isinstance(self.data["labels"], list): - pr_types = self.data["labels"] - elif isinstance(self.data["labels"], str): - pr_types = self.data["labels"].split(",") - pr_types = [name for name in (_label_name(label) for label in pr_types) if name] + pr_types = self.data["labels"].copy() # convert lowercase labels to original case try: diff --git a/pr_agent/tools/pr_help_docs.py b/pr_agent/tools/pr_help_docs.py index ffcdb72cc0..9b8ecc308b 100644 --- a/pr_agent/tools/pr_help_docs.py +++ b/pr_agent/tools/pr_help_docs.py @@ -7,12 +7,12 @@ from jinja2 import Environment, StrictUndefined -from pr_agent.algo import MAX_TOKENS from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.algo.pr_processing import retry_with_fallback_models +from pr_agent.algo.token_budget import clip_tokens, get_max_tokens from pr_agent.algo.token_handler import TokenHandler -from pr_agent.algo.utils import ModelType, clip_tokens, get_max_tokens, load_yaml +from pr_agent.algo.utils import ModelType, load_yaml from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider_with_context from pr_agent.log import get_logger @@ -69,6 +69,9 @@ def extract_model_answer_and_relevant_sources(ai_response: str) -> str | None: get_logger().info(f"Found model answer: {model_answer_section_in_response}") return model_answer_and_relevant_sources_sections_in_response \ if len(model_answer_section_in_response) > 0 else None + if model_answer_and_relevant_sources_sections_in_response.strip(): + get_logger().info(f"Found model answer without relevant sources: {model_answer_and_relevant_sources_sections_in_response}") + return model_answer_and_relevant_sources_sections_in_response get_logger().warning(f"Either no answer section found, or that section is malformed: {ai_response}") return None @@ -112,7 +115,7 @@ def return_document_headings(text: str, ext: str) -> str: return "" return '\n'.join(headings) - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty result.") return "" @@ -138,7 +141,7 @@ def map_documentation_files_to_contents(base_path: str, doc_files: list[str], ma if not returned_dict: get_logger().error("Couldn't find any usable documentation files. Returning empty dict.") return returned_dict - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty dict.") return {} @@ -162,32 +165,48 @@ def aggregate_documentation_files_for_prompt_contents(file_path_to_contents: dic else: docs_prompt += f"\n==file name==\n\n{file_path}\n\n==file content==\n\n{file_contents}\n=========\n\n" return docs_prompt - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty result.") return "" def format_markdown_q_and_a_response(question_str: str, response_str: str, relevant_sections: list[dict[str, str]], supported_suffixes: list[str], base_url_prefix: str, base_url_suffix: str="") -> str: try: - base_url_prefix = base_url_prefix.strip('/') #Sanitize base_url_prefix + base_url_prefix = base_url_prefix.strip('/') # Sanitize base_url_prefix answer_str = "" answer_str += f"### Question: \n{question_str}\n\n" answer_str += f"### Answer:\n{response_str.strip()}\n\n" - answer_str += "#### Relevant Sources:\n\n" + source_links = [] + if not isinstance(relevant_sections, list): + get_logger().warning("Skipping malformed relevant source collection: expected a list") + relevant_sections = [] for section in relevant_sections: - file = section.get('file_name').lstrip('/').strip() #Remove any '/' in the beginning, since some models do it anyway + try: + if not isinstance(section, dict): + raise ValueError("source row is not a mapping") + file_name = section.get('file_name') + header = section.get('relevant_section_header_string') + if not isinstance(file_name, str) or not isinstance(header, str): + raise ValueError("source row has an invalid file name or section header") + except ValueError as e: + get_logger().warning(f"Skipping malformed relevant source row: {e}") + continue + file = file_name.lstrip('/').strip() # Remove any leading '/', since some models add one ext = [suffix for suffix in supported_suffixes if file.endswith(suffix)] if not ext: get_logger().warning(f"Unsupported file extension: {file}") continue - if str(section['relevant_section_header_string']).strip(): - markdown_header = format_markdown_header(section['relevant_section_header_string']) + if header.strip(): + markdown_header = format_markdown_header(header) if base_url_prefix: - answer_str += f"> - {base_url_prefix}/{file}{base_url_suffix}#{markdown_header}\n" + source_links.append(f"> - {base_url_prefix}/{file}{base_url_suffix}#{markdown_header}\n") else: - answer_str += f"> - {base_url_prefix}/{file}{base_url_suffix}\n" + source_links.append(f"> - {base_url_prefix}/{file}{base_url_suffix}\n") + if source_links: + answer_str += "#### Relevant Sources:\n\n" + answer_str += ''.join(source_links) return answer_str - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty result.") return "" @@ -218,6 +237,29 @@ def format_markdown_header(header: str) -> str: get_logger().exception("Error while formatting markdown header", artifacts={'header': header}) return "" + +def get_valid_ranking_indices(ranking: list[dict], number_of_documents: int) -> list[int]: + """Return in-range document indices while isolating malformed ranking rows.""" + valid_indices = [] + if not isinstance(ranking, list): + get_logger().warning("Skipping malformed relevant file ranking collection: expected a list") + return valid_indices + for entry in ranking: + try: + if not isinstance(entry, dict): + raise ValueError("ranking row is not a mapping") + raw_index = entry.get('idx') + if isinstance(raw_index, bool) or not isinstance(raw_index, (int, str)): + raise ValueError("ranking index has an invalid type") + index = int(raw_index) + if 0 <= index < number_of_documents: + valid_indices.append(index) + else: + raise ValueError("ranking index is out of range") + except (TypeError, ValueError) as e: + get_logger().warning(f"Skipping malformed relevant file ranking row: {e}") + return valid_indices + def clean_markdown_content(content: str) -> str: """ Remove hidden comments and unnecessary elements from markdown content to reduce size. @@ -252,7 +294,7 @@ def clean_markdown_content(content: str) -> str: content = re.sub(r'<(?!table|tr|td|th|thead|tbody)([a-zA-Z][a-zA-Z0-9]*)[^>]*>(.*?)', r'\2', content, flags=re.DOTALL) return content.strip() - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty result.") return "" @@ -264,7 +306,7 @@ def __init__(self, ai_handler, vars, system_prompt, user_prompt): environment = Environment(undefined=StrictUndefined) self.system_prompt = environment.from_string(system_prompt).render(variables) self.user_prompt = environment.from_string(user_prompt).render(variables) - except Exception as e: + except Exception: get_logger().exception("Caught exception during init. Setting ai_handler to None to prevent __call__.") self.ai_handler = None @@ -322,7 +364,7 @@ def __init__(self, ctx_url, ai_handler:partial[BaseAiHandler,] = LiteLLMAIHandle self.vars, get_settings().pr_help_docs_prompts.system, get_settings().pr_help_docs_prompts.user) - except Exception as e: + except Exception: get_logger().exception("Caught exception during init. Setting self.question to None to prevent run() to do anything.") self.question = None @@ -371,9 +413,11 @@ async def run(self): return response_str = response_yaml.get('response') relevant_sections = response_yaml.get('relevant_sections') - if not response_str or not relevant_sections: - get_logger().error("Failed to extract response/relevant sections.", - artifacts={'raw_response': response, 'response_str': response_str, 'relevant_sections': relevant_sections}) + if not response_str: + get_logger().error( + "Failed to extract response.", + artifacts={'raw_response': response, 'response_str': response_str, + 'relevant_sections': relevant_sections}) return if int(response_yaml.get('question_is_relevant', '1')) == 0: get_logger().warning("Question is not relevant. Returning without an answer...", @@ -390,7 +434,7 @@ async def run(self): else: get_logger().info("Answer:", artifacts={'answer_str': answer_str}) return answer_str - except Exception as e: + except Exception: get_logger().exception('failed to provide answer to given user question as a result of a thrown exception (see above)') def _find_all_document_files_matching_exts(self, abs_docs_path: str, ignore_readme=False, max_allowed_files=5000) -> list[str]: @@ -414,10 +458,28 @@ def _find_all_document_files_matching_exts(self, abs_docs_path: str, ignore_read get_logger().warning(f"Found at least {max_allowed_files} files in {abs_docs_path}, skipping the rest.") return matching_files return matching_files - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty list.") return [] + def _filter_doc_files_under_clone(self, doc_files: list[str], clone_root: str) -> list[str]: + """Drop candidates whose resolved path leaves the cloned repository root. + + Symlinked documentation files can point outside the clone at host files; resolving + each candidate keeps anything that escapes from ever being read into the model prompt. + """ + contained = [] + for file_path in doc_files: + resolved = os.path.realpath(file_path) + if resolved != clone_root and not resolved.startswith(clone_root + os.sep): + get_logger().warning( + f"Documentation file '{file_path}' resolves outside the cloned repository " + "root; skipping it" + ) + continue + contained.append(file_path) + return contained + def _gen_filenames_to_contents_map_from_repo(self) -> dict[str, str]: try: with TemporaryDirectory() as tmp_dir: @@ -435,22 +497,36 @@ def _gen_filenames_to_contents_map_from_repo(self) -> dict[str, str]: for file in files: if file.lower().startswith("readme."): doc_files.append(os.path.join(root, file)) - abs_docs_path = os.path.join(returned_cloned_repo_root.path, self.docs_path) - if os.path.exists(abs_docs_path): - doc_files.extend(self._find_all_document_files_matching_exts(abs_docs_path, - ignore_readme=(self.docs_path=='.'))) - if not doc_files: - get_logger().warning(f"No documentation files found matching file extensions: " - f"{self.supported_doc_exts} under repo: {self.repo_url} " - f"path: {self.docs_path}. Returning empty dict.") - return {} + # Resolve docs_path against the clone root and refuse paths that escape it: + # an absolute or traversing docs_path (e.g. an attacker-controlled per-directory + # setting) must never read host files into the prompt. + clone_root = os.path.realpath(returned_cloned_repo_root.path) + resolved_docs_path = os.path.realpath(os.path.join(clone_root, self.docs_path)) + if resolved_docs_path == clone_root or resolved_docs_path.startswith(clone_root + os.sep): + if os.path.exists(resolved_docs_path): + doc_files.extend(self._find_all_document_files_matching_exts(resolved_docs_path, + ignore_readme=(self.docs_path=='.'))) + else: + get_logger().warning( + f"docs_path '{self.docs_path}' escapes the cloned repository root; " + "skipping docs-path file gathering" + ) + # Individual files can still escape the clone through symlinks (a doc or root + # README that points at a host file). Resolve every candidate and refuse + # anything that leaves the canonical clone root so host files never reach the prompt. + doc_files = self._filter_doc_files_under_clone(doc_files, clone_root) + if not doc_files: + get_logger().warning(f"No documentation files found matching file extensions: " + f"{self.supported_doc_exts} under repo: {self.repo_url} " + f"path: {self.docs_path}. Returning empty dict.") + return {} get_logger().info(f'For context {self.ctx_url} and repo: {self.repo_url}' f' will be using the following documentation files: ', artifacts={'doc_files': doc_files}) return map_documentation_files_to_contents(returned_cloned_repo_root.path, doc_files) - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty dict.") return {} @@ -465,12 +541,9 @@ def _trim_docs_input(self, docs_input: str, max_allowed_txt_input: int, only_ret # Then, count the tokens in the prompt. If the count exceeds the limit, trim the text. token_count = self.token_handler.count_tokens(docs_input, force_accurate=True) get_logger().debug(f"Estimated token count of documentation to send to model: {token_count}") - model = get_settings().config.model - if model in MAX_TOKENS: - max_tokens_full = MAX_TOKENS[ - model] # note - here we take the actual max tokens, without any reductions. we do aim to get the full documentation website in the prompt - else: - max_tokens_full = get_max_tokens(model) + # take the actual max tokens, without any reductions. we do aim to get + # the full documentation website in the prompt + max_tokens_full = get_max_tokens(get_settings().config.model, ignore_max_model_tokens=True) delta_output = 5000 # Elbow room to reduce chance of exceeding token limit or model paying less attention to prompt guidelines. if token_count > max_tokens_full - delta_output: if only_return_if_trim_needed: @@ -515,8 +588,8 @@ async def _rank_docs_and_return_them_as_prompt(self, docs_filepath_to_contents: get_logger().error("Failed to parse the AI response.", artifacts={'response': response}) return "" # else: Sanitize the output so that the file names match 1:1 dictionary keys. Do this via the file index and not its name, which may be altered by the model. - valid_indices = [int(entry['idx']) for entry in response_yaml.get('relevant_files_ranking') - if int(entry['idx']) >= 0 and int(entry['idx']) < len(docs_filepath_to_contents)] + valid_indices = get_valid_ranking_indices(response_yaml.get('relevant_files_ranking'), + len(docs_filepath_to_contents)) valid_file_paths = [list(docs_filepath_to_contents.keys())[idx] for idx in valid_indices] selected_docs_dict = {file_path: docs_filepath_to_contents[file_path] for file_path in valid_file_paths} docs_prompt = aggregate_documentation_files_for_prompt_contents(selected_docs_dict) @@ -529,7 +602,7 @@ async def _rank_docs_and_return_them_as_prompt(self, docs_filepath_to_contents: get_logger().error("_trim_docs_input returned an empty result.") return "" return docs_prompt_to_send_to_model - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty result.") return "" @@ -555,6 +628,6 @@ def _format_model_answer(self, response_str: str, relevant_sections: list[dict[s answer_str += HelpMessage.get_help_docs_usage_guide() answer_str += "\n\n" return answer_str - except Exception as e: + except Exception: get_logger().exception("Unexpected exception thrown. Returning empty result.") return "" diff --git a/pr_agent/tools/pr_help_message.py b/pr_agent/tools/pr_help_message.py index ae767aebd4..880565e055 100644 --- a/pr_agent/tools/pr_help_message.py +++ b/pr_agent/tools/pr_help_message.py @@ -1,34 +1,139 @@ import copy import re from functools import partial -from pathlib import Path +from importlib.resources import files as package_files +from importlib.resources.abc import Traversable +from math import ceil, isfinite +from pathlib import Path, PurePosixPath -from jinja2 import Environment, StrictUndefined +from jinja2 import Environment, StrictUndefined, select_autoescape +from litellm import token_counter -from pr_agent.algo import MAX_TOKENS from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.algo.pr_processing import retry_with_fallback_models -from pr_agent.algo.token_handler import TokenHandler -from pr_agent.algo.utils import ModelType, clip_tokens, get_max_tokens, load_yaml +from pr_agent.algo.token_budget import get_max_tokens +from pr_agent.algo.token_handler import TokenEncoder +from pr_agent.algo.utils import ModelType, load_yaml from pr_agent.command_descriptions import COMMAND_DESCRIPTIONS from pr_agent.config_loader import get_settings -from pr_agent.git_providers import BitbucketServerProvider, GithubProvider, get_git_provider_with_context +from pr_agent.git_providers import get_git_provider_with_context from pr_agent.log import get_logger +DOCS_SITE_URL = "https://docs.pr-agent.ai" +HELP_OUTPUT_TOKEN_RESERVE = 2_000 +MESSAGE_FRAMING_TOKEN_ALLOWANCE = 16 +REPLY_FRAMING_TOKEN_ALLOWANCE = 16 +TRUNCATION_MARKER = "\n...(truncated)\n" +HELP_DOCS_RESOURCE = "_help_docs" +HELP_DOCS_UNAVAILABLE_MESSAGE = ( + "PR-Agent help documentation is unavailable. Please contact the service administrator." +) +HELP_DOCS_EXCLUDED_DIRECTORIES = {"finetuning_benchmark"} +HELP_DOCS_EXCLUDED_FILENAMES = {"compression_strategy.md"} +HELP_DOCS_PRIORITY_PATHS = ( + "/usage-guide/", + "/tools/describe.md", + "/tools/review.md", + "/tools/improve.md", + "/faq/", +) + + +def _is_help_doc_included(relative_path: PurePosixPath) -> bool: + return ( + relative_path.suffix == ".md" + and relative_path.name not in HELP_DOCS_EXCLUDED_FILENAMES + and not any(part in HELP_DOCS_EXCLUDED_DIRECTORIES for part in relative_path.parts[:-1]) + ) + + +def _get_help_docs_root() -> Traversable: + try: + packaged_docs = package_files("pr_agent").joinpath(HELP_DOCS_RESOURCE) + if packaged_docs.is_dir(): + return packaged_docs + except Exception: + get_logger().opt(exception=True).debug("Unable to inspect packaged /help documentation") + + try: + source_docs = Path(__file__).resolve().parents[2] / "docs" / "docs" + if source_docs.is_dir(): + return source_docs + except Exception as error: + get_logger().opt(exception=True).error("Unable to inspect source-tree /help documentation") + raise FileNotFoundError( + "The /help documentation corpus is unavailable; unable to inspect the source checkout" + ) from error + + raise FileNotFoundError( + "The /help documentation corpus is unavailable; reinstall PR-Agent from a complete package" + ) + + +def _iter_help_docs(root: Traversable) -> list[tuple[PurePosixPath, Traversable]]: + documents: list[tuple[PurePosixPath, Traversable]] = [] + + def visit(directory: Traversable, relative_directory: PurePosixPath) -> None: + try: + children = sorted(directory.iterdir(), key=lambda child: child.name) + except Exception: + get_logger().opt(exception=True).error( + f"Error while listing the documentation directory {directory}" + ) + return + + for child in children: + relative_path = relative_directory / child.name + try: + if child.is_dir(): + if child.name not in HELP_DOCS_EXCLUDED_DIRECTORIES: + visit(child, relative_path) + elif child.is_file() and _is_help_doc_included(relative_path): + documents.append((relative_path, child)) + except Exception: + get_logger().opt(exception=True).error( + f"Error while inspecting the documentation path {child}" + ) + + visit(root, PurePosixPath()) + + def sort_key(document: tuple[PurePosixPath, Traversable]) -> tuple[int, str]: + relative_name = f"/{document[0].as_posix()}" + priority_rank = 0 if relative_name == "/index.md" else 1 + if priority_rank and not any(priority in relative_name for priority in HELP_DOCS_PRIORITY_PATHS): + priority_rank = 2 + return priority_rank, relative_name + + return sorted(documents, key=sort_key) + + +def _load_help_docs_prompt() -> tuple[str, set[str]]: + docs_root = _get_help_docs_root() + sections = [] + available_docs_files = set() + for relative_path, document in _iter_help_docs(docs_root): + try: + content = document.read_text(encoding="utf-8").strip() + except Exception: + get_logger().opt(exception=True).error( + f"Error while reading the documentation file {document}" + ) + continue + if not content: + continue + relative_file_path = relative_path.as_posix() + sections.append( + f"\n==file name==\n\n/{relative_file_path}\n\n" + f"==file content==\n\n{content}\n=========\n\n" + ) + available_docs_files.add(relative_file_path) + + if not sections: + raise FileNotFoundError(f"No readable Markdown documentation was found for /help in {docs_root}") + + return "".join(sections).strip(), available_docs_files -def extract_header(snippet): - res = '' - lines = snippet.split('===Snippet content===')[0].split('\n') - highest_header = '' - highest_level = float('inf') - for line in lines[::-1]: - line = line.strip() - if line.startswith('Header '): - highest_header = line.split(': ')[1] - if highest_header: - res = f"#{highest_header.lower().replace(' ', '-')}" - return res class PRHelpMessage: def __init__(self, pr_url: str, args=None, ai_handler: partial[BaseAiHandler,] = LiteLLMAIHandler, return_as_string=False): @@ -41,23 +146,198 @@ def __init__(self, pr_url: str, args=None, ai_handler: partial[BaseAiHandler,] = "question": self.question_str, "snippets": "", } - self.token_handler = TokenHandler(None, - self.vars, - get_settings().pr_help_prompts.system, - get_settings().pr_help_prompts.user) async def _prepare_prediction(self, model: str): + variables = copy.deepcopy(self.vars) + system_prompt, user_prompt, fitted_snippets = self._fit_prompts(variables, model) + response, finish_reason = await self.ai_handler.chat_completion( + model=model, temperature=get_settings().config.temperature, system=system_prompt, user=user_prompt) + self._model_visible_docs_files = self._get_fitted_docs_files( + variables.get("snippets", ""), + fitted_snippets, + getattr(self, "_available_docs_files", set()), + ) + return response + + @staticmethod + def _render_prompts(variables): + # These string templates produce plain-text model prompts, not HTML. + environment = Environment( + autoescape=select_autoescape(default_for_string=False), + undefined=StrictUndefined, + ) + system_prompt = environment.from_string(get_settings().pr_help_prompts.system).render(variables) + user_prompt = environment.from_string(get_settings().pr_help_prompts.user).render(variables) + return system_prompt, user_prompt + + @staticmethod + def _coerce_output_token_limit(value) -> int: try: - variables = copy.deepcopy(self.vars) - environment = Environment(undefined=StrictUndefined) - system_prompt = environment.from_string(get_settings().pr_help_prompts.system).render(variables) - user_prompt = environment.from_string(get_settings().pr_help_prompts.user).render(variables) - response, finish_reason = await self.ai_handler.chat_completion( - model=model, temperature=get_settings().config.temperature, system=system_prompt, user=user_prompt) - return response + output_tokens = int(value) + except (TypeError, ValueError, OverflowError): + return 0 + return output_tokens if output_tokens > 0 else 0 + + def _get_prompt_budget(self, model: str) -> int: + output_tokens = 0 + get_output_token_reserve = getattr(self.ai_handler, "get_output_token_reserve", None) + if callable(get_output_token_reserve): + try: + handler_output_tokens = get_output_token_reserve(model, HELP_OUTPUT_TOKEN_RESERVE) + except Exception as e: + get_logger().debug(f"Failed to resolve the output token reserve for {model}: {e}") + else: + if ( + isinstance(handler_output_tokens, int) + and not isinstance(handler_output_tokens, bool) + and handler_output_tokens > 0 + ): + output_tokens = handler_output_tokens + get_output_token_limit = getattr(self.ai_handler, "get_output_token_limit", None) + if output_tokens <= 0 and callable(get_output_token_limit): + try: + handler_output_tokens = get_output_token_limit(model) + except Exception as e: + get_logger().debug(f"Failed to resolve the output token limit for {model}: {e}") + else: + if ( + isinstance(handler_output_tokens, int) + and not isinstance(handler_output_tokens, bool) + and handler_output_tokens > 0 + ): + output_tokens = handler_output_tokens + if output_tokens <= 0: + raw_output_tokens = get_settings().config.get("max_output_tokens", 0) + output_tokens = self._coerce_output_token_limit(raw_output_tokens) + if output_tokens <= 0: + output_tokens = HELP_OUTPUT_TOKEN_RESERVE + return max(get_max_tokens(model, ignore_max_model_tokens=True) - output_tokens, 0) + + @staticmethod + def _count_prompt_tokens(model: str, system_prompt: str, user_prompt: str) -> int: + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + try: + model_token_count = token_counter(model=model, messages=messages) + if isinstance(model_token_count, int) and not isinstance(model_token_count, bool) and model_token_count > 0: + return model_token_count except Exception as e: - get_logger().error(f"Error while preparing prediction: {e}") - return "" + get_logger().debug(f"Model-aware token counting failed for {model}: {e}") + + get_logger().debug(f"Using a local token estimate for {model}") + encoder = TokenEncoder.get_token_encoder(model) + content_tokens = sum( + len(encoder.encode(message["content"], disallowed_special=())) for message in messages + ) + framing_tokens = MESSAGE_FRAMING_TOKEN_ALLOWANCE * len(messages) + REPLY_FRAMING_TOKEN_ALLOWANCE + raw_factor = get_settings().get("config.model_token_count_estimate_factor", 0) + try: + extra_factor = float(raw_factor) + except (TypeError, ValueError, OverflowError): + extra_factor = 0 + if isinstance(raw_factor, bool) or not isfinite(extra_factor): + extra_factor = 0 + multiplier = max(1.0, 1.0 + extra_factor) + raw_estimate = content_tokens + framing_tokens + try: + estimated_tokens = raw_estimate * multiplier + if not isfinite(estimated_tokens): + raise ValueError("non-finite token estimate") + return ceil(estimated_tokens) + except (OverflowError, ValueError): + get_logger().warning( + f"model_token_count_estimate_factor is too large ({raw_factor!r}), using the estimate as is" + ) + return raw_estimate + + def _normalize_request_prompts(self, model: str, system_prompt: str, user_prompt: str) -> tuple[str, str]: + normalize_request_prompts = getattr(self.ai_handler, "normalize_request_prompts", None) + if not callable(normalize_request_prompts): + return system_prompt, user_prompt + try: + normalized_prompts = normalize_request_prompts(model, system_prompt, user_prompt) + except Exception as e: + get_logger().debug(f"Failed to normalize prompts for {model}: {e}") + return system_prompt, user_prompt + if ( + isinstance(normalized_prompts, tuple) + and len(normalized_prompts) == 2 + and all(isinstance(prompt, str) for prompt in normalized_prompts) + ): + return normalized_prompts + get_logger().debug(f"Ignoring unusable prompt normalization result for {model}") + return system_prompt, user_prompt + + def _fit_prompts(self, variables, model: str): + prompt_budget = self._get_prompt_budget(model) + raw_snippets = variables.get("snippets", "") + + def render(snippets): + attempt_variables = copy.deepcopy(variables) + attempt_variables["snippets"] = snippets + rendered_prompts = self._render_prompts(attempt_variables) + return self._normalize_request_prompts(model, *rendered_prompts) + + full_prompts = render(raw_snippets) + if self._count_prompt_tokens(model, *full_prompts) <= prompt_budget: + return full_prompts[0], full_prompts[1], raw_snippets + + empty_prompts = render("") + if self._count_prompt_tokens(model, *empty_prompts) > prompt_budget: + raise ValueError(f"The /help prompt exceeds the token limit for {model} without documentation") + + marker_prompts = render(TRUNCATION_MARKER) + if self._count_prompt_tokens(model, *marker_prompts) > prompt_budget: + raise ValueError(f"The /help prompt exceeds the token limit for {model} with a truncation marker") + + keep_chars = max(len(raw_snippets) - 1, 0) + while keep_chars > 0: + candidate = raw_snippets[:keep_chars].rstrip() + TRUNCATION_MARKER + candidate_prompts = render(candidate) + candidate_tokens = self._count_prompt_tokens(model, *candidate_prompts) + if candidate_tokens <= prompt_budget: + get_logger().warning( + f"Documentation was clipped for /help to fit the {prompt_budget}-token input limit for {model}" + ) + return candidate_prompts[0], candidate_prompts[1], candidate + next_keep_chars = max(1, int(keep_chars * prompt_budget / candidate_tokens)) + keep_chars = min(keep_chars - 1, next_keep_chars) + + get_logger().warning( + f"Documentation was clipped for /help to fit the {prompt_budget}-token input limit for {model}" + ) + return marker_prompts[0], marker_prompts[1], TRUNCATION_MARKER + + @staticmethod + def _get_fitted_docs_files( + raw_snippets: str, + fitted_snippets: str, + available_docs_files: set[str], + ) -> set[str]: + if not raw_snippets or not fitted_snippets or not available_docs_files: + return set() + + section_starts = [] + for file_name in available_docs_files: + section_prefix = f"==file name==\n\n/{file_name}\n\n==file content==\n\n" + section_start = raw_snippets.find(section_prefix) + if section_start >= 0: + section_starts.append((section_start, file_name)) + section_starts.sort() + + fitted_docs_files = set() + for index, (section_start, file_name) in enumerate(section_starts): + section_end = section_starts[index + 1][0] if index + 1 < len(section_starts) else len(raw_snippets) + section = raw_snippets[section_start:section_end] + delimiter_start = section.rfind("\n=========") + if delimiter_start < 0: + continue + complete_document = section[:delimiter_start] + if complete_document and complete_document in fitted_snippets: + fitted_docs_files.add(file_name) + return fitted_docs_files def parse_args(self, args): if args and len(args) > 0: @@ -93,58 +373,64 @@ def format_markdown_header(self, header: str) -> str: get_logger().exception("Error while formatting markdown header", artifacts={'header': header}) return "" + def format_docs_url(self, file_name: str, header: str) -> str: + relative_path = file_name.strip().lstrip('/').removesuffix('.md') + if relative_path == 'index': + relative_path = '' + elif relative_path.endswith('/index'): + relative_path = relative_path.removesuffix('index') + elif relative_path: + relative_path += '/' + + docs_url = f"{DOCS_SITE_URL}/{relative_path}" + if str(header).strip(): + docs_url += f"#{self.format_markdown_header(header)}" + return docs_url + + def _format_relevant_sources(self, relevant_sections, available_docs_files: set[str]) -> list[str]: + if not isinstance(relevant_sections, list): + return [] + + source_urls = [] + for section in relevant_sections: + if not isinstance(section, dict): + continue + + file_name = section.get("file_name") + if not isinstance(file_name, str): + continue + normalized_file_name = file_name + if normalized_file_name.startswith("/"): + normalized_file_name = normalized_file_name[1:] + if normalized_file_name not in available_docs_files: + get_logger().warning(f"Skipping a /help citation to a document that was not loaded: {file_name}") + continue + + header = section.get("relevant_section_header_string", "") + if not isinstance(header, str): + header = "" + source_urls.append(self.format_docs_url(normalized_file_name, header)) + return source_urls + async def run(self): + if self.question_str: + try: + self.vars["snippets"], self._available_docs_files = _load_help_docs_prompt() + self._model_visible_docs_files = set() + except FileNotFoundError: + get_logger().exception("Unable to load the PR-Agent help documentation") + if get_settings().config.publish_output: + try: + self.git_provider.publish_comment(HELP_DOCS_UNAVAILABLE_MESSAGE) + except Exception: + get_logger().exception("Unable to publish the help documentation failure message") + raise + try: if self.question_str: get_logger().info(f'Answering a PR question about the PR {self.git_provider.pr_url} ') - if not get_settings().get('openai.key'): - if get_settings().config.publish_output: - self.git_provider.publish_comment( - "The `Help` tool chat feature requires an OpenAI API key for calculating embeddings") - else: - get_logger().error("The `Help` tool chat feature requires an OpenAI API key for calculating embeddings") - return - - # current path - docs_path= Path(__file__).parent.parent.parent / 'docs' / 'docs' - # get all the 'md' files inside docs_path and its subdirectories - md_files = list(docs_path.glob('**/*.md')) - folders_to_exclude = ['/finetuning_benchmark/'] - files_to_exclude = {'EXAMPLE_BEST_PRACTICE.md', 'compression_strategy.md', '/docs/overview/index.md'} - md_files = [file for file in md_files if not any(folder in str(file) for folder in folders_to_exclude) and not any(file.name == file_to_exclude for file_to_exclude in files_to_exclude)] - - # sort the 'md_files' so that 'priority_files' will be at the top - priority_files_strings = ['/docs/index.md', '/usage-guide', 'tools/describe.md', 'tools/review.md', - 'tools/improve.md', '/faq'] - md_files_priority = [file for file in md_files if - any(priority_string in str(file) for priority_string in priority_files_strings)] - md_files_not_priority = [file for file in md_files if file not in md_files_priority] - md_files = md_files_priority + md_files_not_priority - - docs_prompt = "" - for file in md_files: - try: - with open(file, 'r') as f: - file_path = str(file).replace(str(docs_path), '') - docs_prompt += f"\n==file name==\n\n{file_path}\n\n==file content==\n\n{f.read().strip()}\n=========\n\n" - except Exception as e: - get_logger().error(f"Error while reading the file {file}: {e}") - token_count = self.token_handler.count_tokens(docs_prompt) - get_logger().debug(f"Token count of full documentation website: {token_count}") - - model = get_settings().config.model - if model in MAX_TOKENS: - max_tokens_full = MAX_TOKENS[model] # note - here we take the actual max tokens, without any reductions. we do aim to get the full documentation website in the prompt - else: - max_tokens_full = get_max_tokens(model) - delta_output = 2000 - if token_count > max_tokens_full - delta_output: - get_logger().info(f"Token count {token_count} exceeds the limit {max_tokens_full - delta_output}. Skipping the PR Help message.") - docs_prompt = clip_tokens(docs_prompt, max_tokens_full - delta_output) - self.vars['snippets'] = docs_prompt.strip() - # run the AI model response = await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.REGULAR) response_yaml = load_yaml(response) @@ -171,17 +457,13 @@ async def run(self): # prepare the answer answer_str = "" if response_str: + source_urls = self._format_relevant_sources(relevant_sections, self._model_visible_docs_files) answer_str += f"### Question: \n{self.question_str}\n\n" answer_str += f"### Answer:\n{response_str.strip()}\n\n" - answer_str += "#### Relevant Sources:\n\n" - base_path = "https://qodo-merge-docs.qodo.ai/" - for section in relevant_sections: - file = section.get('file_name').strip().removesuffix('.md') - if str(section['relevant_section_header_string']).strip(): - markdown_header = self.format_markdown_header(section['relevant_section_header_string']) - answer_str += f"> - {base_path}{file}#{markdown_header}\n" - else: - answer_str += f"> - {base_path}{file}\n" + if source_urls: + answer_str += "#### Relevant Sources:\n\n" + for docs_url in source_urls: + answer_str += f"> - {docs_url}\n" # publish the answer @@ -190,19 +472,20 @@ async def run(self): else: get_logger().info(f"Answer:\n{answer_str}") else: - if not isinstance(self.git_provider, BitbucketServerProvider) and not self.git_provider.is_supported("gfm_markdown"): + supports_gfm_markdown = self.git_provider.is_supported("gfm_markdown") + if not supports_gfm_markdown and not self.git_provider.supports_markdown_tables(): self.git_provider.publish_comment( "The `Help` tool requires gfm markdown, which is not supported by your code platform.") return get_logger().info('Getting PR Help Message...') - relevant_configs = {'pr_help': dict(get_settings().pr_help), + relevant_configs = {'pr_help': dict(get_settings().get("pr_help", {})), 'config': dict(get_settings().config)} get_logger().debug("Relevant configs", artifacts=relevant_configs) pr_comment = "## PR Agent Walkthrough 🤖\n\n" pr_comment += "Welcome to the PR Agent, an AI-powered tool for automated pull request analysis, feedback, suggestions and more.""" pr_comment += "\n\nHere is a list of tools you can use to interact with the PR Agent:\n" - base_path = "https://pr-agent-docs.codium.ai/tools" + base_path = f"{DOCS_SITE_URL}/tools" tool_names = [] tool_names.append(f"[DESCRIBE]({base_path}/describe/)") @@ -242,22 +525,23 @@ async def run(self): checkbox_list.append("[*]") checkbox_list.append("[*]") - if isinstance(self.git_provider, GithubProvider) and not get_settings().config.get('disable_checkboxes', False): + if (supports_gfm_markdown and self.git_provider.supports_checkbox_commands() + and not get_settings().config.get('disable_checkboxes', False)): pr_comment += "
    " for i in range(len(tool_names)): pr_comment += f"\n\n\n" pr_comment += "
    ToolDescriptionTrigger Interactively :gem:
    \n\n{tool_names[i]}{descriptions[i]}\n\n{checkbox_list[i]}\n
    \n\n" - pr_comment += """\n\n(1) Note that each tool can be [triggered automatically](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) when a new PR is opened, or called manually by [commenting on a PR](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/#online-usage).""" + pr_comment += """\n\n(1) Note that each tool can be [triggered automatically](https://docs.pr-agent.ai/usage-guide/automations_and_usage/#github-app-automatic-tools-when-a-new-pr-is-opened) when a new PR is opened, or called manually by [commenting on a PR](https://docs.pr-agent.ai/usage-guide/automations_and_usage/#online-usage).""" pr_comment += """\n\n(2) Tools marked with [*] require additional parameters to be passed. For example, to invoke the `/ask` tool, you need to comment on a PR: `/ask ""`. See the relevant documentation for each tool for more details.""" - elif isinstance(self.git_provider, BitbucketServerProvider): - # only support basic commands in BBDC + elif not supports_gfm_markdown: + # only basic commands, in a plain markdown table (e.g. BBDC) pr_comment = generate_bbdc_table(tool_names[:4], descriptions[:4]) else: pr_comment += "" for i in range(len(tool_names)): pr_comment += f"\n" pr_comment += "
    ToolCommandDescription
    \n\n{tool_names[i]}{commands[i]}{descriptions[i]}
    \n\n" - pr_comment += """\n\nNote that each tool can be [invoked automatically](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/) when a new PR is opened, or called manually by [commenting on a PR](https://pr-agent-docs.codium.ai/usage-guide/automations_and_usage/#online-usage).""" + pr_comment += """\n\nNote that each tool can be [invoked automatically](https://docs.pr-agent.ai/usage-guide/automations_and_usage/) when a new PR is opened, or called manually by [commenting on a PR](https://docs.pr-agent.ai/usage-guide/automations_and_usage/#online-usage).""" if get_settings().config.publish_output: self.git_provider.publish_comment(pr_comment) @@ -265,26 +549,6 @@ async def run(self): get_logger().exception(f"Error while running PRHelpMessage: {e}") return "" - async def prepare_relevant_snippets(self, sim_results): - # Get relevant snippets - relevant_snippets_full = [] - relevant_pages_full = [] - relevant_snippets_full_header = [] - th = 0.75 - for s in sim_results: - page = s[0].metadata['source'] - content = s[0].page_content - score = s[1] - relevant_snippets_full.append(content) - relevant_snippets_full_header.append(extract_header(content)) - relevant_pages_full.append(page) - # build the snippets string - relevant_snippets_str = "" - for i, s in enumerate(relevant_snippets_full): - relevant_snippets_str += f"Snippet {i+1}:\n\n{s}\n\n" - relevant_snippets_str += "-------------------\n\n" - return relevant_pages_full, relevant_snippets_full_header, relevant_snippets_str - def generate_bbdc_table(column_arr_1, column_arr_2): # Generating header row diff --git a/pr_agent/tools/pr_line_questions.py b/pr_agent/tools/pr_line_questions.py index 6959d260af..d7895e05f2 100644 --- a/pr_agent/tools/pr_line_questions.py +++ b/pr_agent/tools/pr_line_questions.py @@ -3,14 +3,14 @@ from urllib.parse import unquote from jinja2 import Environment, StrictUndefined, select_autoescape -from litellm import token_counter from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler from pr_agent.algo.git_patch_processing import extract_hunk_lines_from_patch from pr_agent.algo.pr_processing import OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, retry_with_fallback_models -from pr_agent.algo.token_handler import TokenEncoder, TokenHandler -from pr_agent.algo.utils import ModelType, decode_user_text_args, get_max_tokens +from pr_agent.algo.token_budget import AttemptTokenBudget +from pr_agent.algo.token_handler import TokenHandler +from pr_agent.algo.utils import ModelType, decode_user_text_args from pr_agent.config_loader import get_settings, get_verbosity_level from pr_agent.git_providers import get_git_provider from pr_agent.git_providers.git_provider import get_main_pr_language @@ -191,8 +191,9 @@ async def _get_prediction(self, model: str): variables = copy.deepcopy(self.vars) variables["full_hunk"] = self.patch_with_lines # update diff variables["selected_lines"] = self.selected_lines - variables["conversation_history"] = self._fit_conversation_history(variables, model) - system_prompt, user_prompt = self._render_prompts(variables) + fitted = self._fit_conversation_history(variables, model) + variables["conversation_history"] = fitted.optional_text + system_prompt, user_prompt = fitted.system_prompt, fitted.user_prompt if get_verbosity_level() >= 2: # get_logger().info(f"\nSystem prompt:\n{system_prompt}") # get_logger().info(f"\nUser prompt:\n{user_prompt}") @@ -214,65 +215,38 @@ def _render_prompts(self, variables): def _fit_conversation_history(self, variables, model): conversation_history = variables.get("conversation_history", "") - encoder = None + variables = copy.deepcopy(variables) + variables["conversation_history"] = "" + output_token_reserve = getattr(self.ai_handler, "get_output_token_reserve", None) try: completion_tokens = int(get_settings().config.get("max_output_tokens", 0)) except (TypeError, ValueError): completion_tokens = 0 - if completion_tokens <= 0: - completion_tokens = OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - max_tokens = max(get_max_tokens(model) - completion_tokens, 0) - - def render_with_history(history): - prompt_variables = copy.deepcopy(variables) - prompt_variables["conversation_history"] = history - return self._render_prompts(prompt_variables) - - def count_prompts(prompts): - nonlocal encoder - system_prompt, user_prompt = prompts - try: - model_token_count = token_counter( - model=model, - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - ) - if model_token_count > 0: - return model_token_count - except Exception as e: - get_logger().debug(f"Model-aware token counting failed for {model}: {e}") - if encoder is None: - encoder = TokenEncoder.get_token_encoder(model) - return len(encoder.encode(system_prompt, disallowed_special=())) + len( - encoder.encode(user_prompt, disallowed_special=())) - - if count_prompts(render_with_history(conversation_history)) <= max_tokens: - return conversation_history - - if count_prompts(render_with_history("")) > max_tokens: - raise ValueError( - f"The /ask_line prompt exceeds the token limit for {model} even without conversation history" - ) - - truncation_marker = "\n...(truncated)\n" - low, high = 0, len(conversation_history) - best_history = "" - while low <= high: - keep_chars = (low + high) // 2 - candidate = ( - truncation_marker + conversation_history[-keep_chars:] - if keep_chars - else "" - ) - if count_prompts(render_with_history(candidate)) <= max_tokens: - best_history = candidate - low = keep_chars + 1 - else: - high = keep_chars - 1 - - get_logger().warning( - f"Conversation history was clipped for /ask_line to fit the {max_tokens}-token input limit" + if not callable(output_token_reserve) and completion_tokens > 0: + def configured_output_reserve(_model, _default): + return completion_tokens + + output_token_reserve = configured_output_reserve + + budget = AttemptTokenBudget.for_prompt_attempt( + model, + getattr(self.git_provider, "pr", None), + variables, + get_settings().pr_line_questions_prompt.system, + get_settings().pr_line_questions_prompt.user, + ai_handler=self.ai_handler, + output_token_reserve=output_token_reserve, ) - return best_history + fitted = budget.fit_prompt_variable( + variables, + "conversation_history", + conversation_history, + ai_handler=self.ai_handler, + default_output_tokens=OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + keep="suffix", + ) + if fitted.optional_text != conversation_history: + get_logger().warning( + f"Conversation history was clipped for /ask_line to fit the token limit for {model}" + ) + return fitted diff --git a/pr_agent/tools/pr_questions.py b/pr_agent/tools/pr_questions.py index e25b4f6c6e..4950787460 100644 --- a/pr_agent/tools/pr_questions.py +++ b/pr_agent/tools/pr_questions.py @@ -1,14 +1,19 @@ import copy from functools import partial -from jinja2 import Environment, StrictUndefined - from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler -from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models +from pr_agent.algo.comment_identity import format_pr_questions_header +from pr_agent.algo.pr_processing import ( + OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + get_pr_diff, + retry_with_fallback_models, +) from pr_agent.algo.skills_loader import get_skills_context +from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler -from pr_agent.algo.utils import ModelType, decode_user_text_args, format_pr_questions_header +from pr_agent.algo.utils import ModelType, decode_user_text_args from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider from pr_agent.git_providers.git_provider import get_main_pr_language @@ -61,27 +66,37 @@ async def run(self): relevant_configs = {'pr_questions': dict(get_settings().pr_questions), 'config': dict(get_settings().config)} get_logger().debug("Relevant configs", artifacts=relevant_configs) + temporary_comment_published = False if get_settings().config.publish_output: self.git_provider.publish_comment("Preparing answer...", is_temporary=True) + temporary_comment_published = True - # identify image - img_path = self.identify_image_in_comment() - if img_path: - get_logger().debug("Image path identified", artifact=img_path) - - await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.WEAK) - - pr_comment = self._prepare_pr_answer() - get_logger().debug("PR output", artifact=pr_comment) - - if self.git_provider.is_supported("gfm_markdown") and get_settings().pr_questions.enable_help_text: - pr_comment += "
    \n\n
    💡 Tool usage guide:
    \n\n" - pr_comment += HelpMessage.get_ask_usage_guide() - pr_comment += "\n
    \n" - - if get_settings().config.publish_output: - self._publish_answer(pr_comment) - self.git_provider.remove_initial_comment() + try: + # identify image + img_path = self.identify_image_in_comment() + if img_path: + get_logger().debug("Image path identified", artifact=img_path) + + await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.WEAK) + + pr_comment = self._prepare_pr_answer() + get_logger().debug("PR output", artifact=pr_comment) + + if self.git_provider.is_supported("gfm_markdown") and get_settings().pr_questions.enable_help_text: + pr_comment += "
    \n\n
    💡 Tool usage guide:
    \n\n" + pr_comment += HelpMessage.get_ask_usage_guide() + pr_comment += "\n
    \n" + + if get_settings().config.publish_output: + self._publish_answer(pr_comment) + finally: + if temporary_comment_published: + try: + self.git_provider.remove_initial_comment() + except Exception as cleanup_error: + get_logger().warning( + f"Failed to remove the temporary question comment: {cleanup_error}" + ) return "" def _publish_answer(self, answer: str): @@ -128,20 +143,83 @@ def identify_image_in_comment(self): return img_path async def _prepare_prediction(self, model: str): - self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model) - if self.patches_diff: - get_logger().debug("PR diff", artifact=self.patches_diff) - self.prediction = await self._get_prediction(model) - else: - get_logger().error("Error getting PR diff") - self.prediction = "" + variables = copy.deepcopy(self.vars) + raw_history = variables.get("conversation_history", "") + variables["conversation_history"] = "" + image_path = variables.get("img_path") + if not isinstance(image_path, str) or not image_path.strip(): + image_path = None + output_token_reserve = getattr(self.ai_handler, "get_output_token_reserve", None) + history_budget = AttemptTokenBudget.for_prompt_attempt( + model, + getattr(self.git_provider, "pr", None), + variables, + get_settings().pr_questions_prompt.system, + get_settings().pr_questions_prompt.user, + ai_handler=self.ai_handler, + image_path=image_path, + output_token_reserve=output_token_reserve, + ) + fitted_history = history_budget.fit_prompt_variable( + variables, + "conversation_history", + raw_history, + ai_handler=self.ai_handler, + default_output_tokens=OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, + preserve_minimum=True, + additional_input_reserve=OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + image_path=image_path, + keep="suffix", + ) + variables["conversation_history"] = fitted_history.optional_text + + budget = AttemptTokenBudget.for_prompt_attempt( + model, + getattr(self.git_provider, "pr", None), + variables, + get_settings().pr_questions_prompt.system, + get_settings().pr_questions_prompt.user, + ai_handler=self.ai_handler, + image_path=image_path, + output_token_reserve=output_token_reserve, + ) + budget.require_input_capacity( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + preserve_minimum=True, + ) + patches_diff = get_pr_diff( + self.git_provider, + budget.token_handler, + model, + output_token_reserve=output_token_reserve, + ) + if not patches_diff: + raise ValueError(f"No PR diff fits the /ask request for {model}") + + fitted = budget.fit_prompt_variable( + variables, + "diff", + patches_diff, + ai_handler=self.ai_handler, + default_output_tokens=OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, + preserve_minimum=True, + image_path=image_path, + ) + if fitted.optional_text != patches_diff: + raise ValueError( + f"The complete packed question diff does not fit the token limit for {model}" + ) + self.patches_diff = fitted.optional_text + self._attempt_system_prompt = fitted.system_prompt + self._attempt_user_prompt = fitted.user_prompt + self._attempt_variables = variables + get_logger().debug("PR diff", artifact=self.patches_diff) + self.prediction = await self._get_prediction(model) async def _get_prediction(self, model: str): - variables = copy.deepcopy(self.vars) - variables["diff"] = self.patches_diff # update diff - environment = Environment(undefined=StrictUndefined) - system_prompt = environment.from_string(get_settings().pr_questions_prompt.system).render(variables) - user_prompt = environment.from_string(get_settings().pr_questions_prompt.user).render(variables) + system_prompt = self._attempt_system_prompt + user_prompt = self._attempt_user_prompt + variables = self._attempt_variables if 'img_path' in variables: img_path = self.vars['img_path'] response, finish_reason = await (self.ai_handler.chat_completion diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index 529230b862..3255ff153a 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -5,10 +5,18 @@ from functools import partial from typing import List, Optional, Tuple -from jinja2 import Environment, StrictUndefined +from pydantic import ValidationError from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler +from pr_agent.algo.comment_identity import ( + PRReviewHeader, + PRReviewIdentity, + add_pr_review_identity, + get_pr_review_comment_identifiers, + hidden_marker_forms, + render_hidden_marker, +) from pr_agent.algo.inline_comment_dedup import ( InlineCommentStore, can_verify_inline_comment_publication, @@ -17,7 +25,11 @@ key_issue_fingerprint, key_issue_location_fingerprint, ) +from pr_agent.algo.output_models import PRReview from pr_agent.algo.pr_processing import ( + OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + PreparedPRDiff, add_ai_metadata_to_diff_files, get_pr_diff, get_pr_multi_diffs, @@ -31,21 +43,21 @@ reconcile_review_findings, ) from pr_agent.algo.review_merge import merge_review_chunks -from pr_agent.algo.run_details import get_run_details, init_run_details +from pr_agent.algo.run_details import get_run_details, init_run_details, record_command_failure, record_model_used +from pr_agent.algo.run_output import ( + github_action_output, + push_outputs, + show_relevant_configurations, + show_run_details, +) from pr_agent.algo.skills_loader import get_skills_context +from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.utils import ( ModelType, - PRReviewHeader, - PRReviewIdentity, - add_pr_review_identity, convert_to_markdown_v2, - get_pr_review_comment_identifiers, - github_action_output, + is_value_no, load_yaml, - push_outputs, - show_relevant_configurations, - show_run_details, ) from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider_with_context @@ -234,6 +246,7 @@ def __init__(self, pr_url: str, is_answer: bool = False, is_auto: bool = False, include_ai_metadata=is_ai_metadata, ), "related_tickets": get_settings().get('related_tickets', []), + "related_tickets_omitted": 0, 'duplicate_prompt_examples': get_settings().config.get('duplicate_prompt_examples', False), "date": datetime.datetime.now().strftime('%Y-%m-%d'), } @@ -256,7 +269,11 @@ def parse_incremental(self, args: List[str]): async def run(self) -> None: init_run_details() + for name in ("_chunked_patches_diff_list", "_chunked_remaining_files_list", "_chunked_results", + "_chunked_primary_model"): + self.__dict__.pop(name, None) progress_response = None + partial_review_error = None review_error = None review_failed = False persistent_write_failed = False @@ -302,8 +319,14 @@ async def run(self) -> None: if get_settings().config.publish_output and not get_settings().config.get('is_auto_command', False): progress_response = self.git_provider.publish_comment("Preparing review...", is_temporary=True) - await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.REGULAR, - git_provider=self.git_provider) + try: + await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.REGULAR, + git_provider=self.git_provider) + except Exception as error: + if not self._merge_cached_review_chunks(): + raise + partial_review_error = error + get_logger().warning("Fallback models exhausted; publishing successful review chunks") if not self.prediction: return None @@ -320,6 +343,7 @@ async def run(self) -> None: self._should_publish_review_no_suggestions(pr_review) or state_changed or state_blocked + or self.review_failed_chunk_count > 0 ) if not should_publish: reason = "Review output is not published" @@ -424,12 +448,14 @@ async def run(self) -> None: if self.incremental.is_incremental else PRReviewIdentity.REGULAR.value ) - pr_review = add_pr_review_identity(pr_review, identity_marker) + pr_review = add_pr_review_identity(pr_review, identity_marker, self.git_provider) self.git_provider.publish_comment(pr_review, **review_thread_kwargs) except Exception as e: review_error = e review_failed = True get_logger().error(f"Failed to review PR: {e}") + # The status of the whole run must not read as success just because the error stopped here. + record_command_failure() if get_settings().config.get("propagate_tool_errors", False): raise finally: @@ -450,6 +476,9 @@ async def run(self) -> None: self.git_provider.publish_comment(_review_failure_comment(review_error)) except Exception as e: get_logger().exception(f"Failed to publish review failure result, error: {e}") + if (partial_review_error is not None and not review_failed + and get_settings().config.get("propagate_tool_errors", False)): + raise partial_review_error def _review_finding_state_enabled(self) -> bool: settings = get_settings() @@ -462,10 +491,6 @@ def _review_finding_state_enabled(self) -> bool: provider = getattr(self, "git_provider", None) if provider is None: return False - publisher = getattr(provider, "publish_persistent_comment", None) - if getattr(publisher, "__func__", None) is GitProvider.publish_persistent_comment: - # Skip generic publishers; they only create comments and cannot safely carry lifecycle state. - return False if ( getattr(getattr(settings, "github", None), "publish_as_check_run", False) and callable(getattr(provider, "_publish_check_run", None)) @@ -488,8 +513,8 @@ def _persistent_publish_succeeded(result) -> bool: @staticmethod def _as_non_authoritative_review(pr_review: str) -> str: identity_markers = { - PRReviewIdentity.REGULAR.value, - PRReviewIdentity.INCREMENTAL.value, + *hidden_marker_forms(PRReviewIdentity.REGULAR.value), + *hidden_marker_forms(PRReviewIdentity.INCREMENTAL.value), } markerless_review = "\n".join( line @@ -655,6 +680,14 @@ def _review_findings_from_data(cls, data: dict) -> Optional[list[dict]]: return findings def _review_head_sha(self) -> str: + # Resolve the head commit through the provider's own hook, so the reviewer does not + # need to know which field each API carries it in. + get_head_sha = getattr(self.git_provider, "get_pr_head_sha", None) + if callable(get_head_sha): + head_sha = get_head_sha() + if isinstance(head_sha, str) and head_sha: + return head_sha + last_commit = getattr(self.git_provider, "last_commit_id", None) if isinstance(last_commit, str): return last_commit @@ -679,7 +712,7 @@ def _review_comment_max_chars(self) -> int | None: # The shared persistent publisher adds the full-review identity # before inserting the update suffix. Reserve both pieces so a # complete state marker remains inside the provider limit. - identity_overhead = len(PRReviewIdentity.REGULAR.value) + 2 + identity_overhead = len(render_hidden_marker(PRReviewIdentity.REGULAR.value, self.git_provider)) + 2 return value - len(update_suffix) - identity_overhead return None @@ -731,6 +764,9 @@ def _prepare_review_finding_state(self, data: dict) -> None: allow_resolution = ( bool(self.prediction) and not bool(getattr(self.incremental, "is_incremental", False)) + # A merged result with failed chunks is still partial, even when chunking left + # no additional token-budget files to report. + and not bool(self.review_failed_chunk_count) and not bool(self.remaining_files_list) and parsed.valid and current_findings is not None @@ -753,7 +789,17 @@ def _should_publish_review_no_suggestions(self, pr_review: str) -> bool: return get_settings().pr_reviewer.get('publish_output_no_suggestions', True) or "No major issues detected" not in pr_review async def _prepare_prediction(self, model: str) -> None: + # Each model attempt owns a fresh result. A malformed primary must not + # leave state that can be mistaken for a successful fallback response. + self.prediction = None + self.prediction_data = None + self.review_chunk_count = 1 + self.review_failed_chunk_count = 0 raw_prompt_vars = getattr(self, "_raw_prompt_vars", getattr(self, "vars", None)) + ai_handler = getattr(self, "ai_handler", None) + output_token_reserve = getattr( + ai_handler, "get_output_token_reserve", None + ) if raw_prompt_vars is not None: self.vars, self.token_handler = fit_related_tickets_to_prompt_budget( self.git_provider.pr, @@ -761,55 +807,93 @@ async def _prepare_prediction(self, model: str) -> None: get_settings().pr_review_prompt.system, get_settings().pr_review_prompt.user, model, + ai_handler=ai_handler, + output_token_reserve=output_token_reserve, ) - output = get_pr_diff(self.git_provider, - self.token_handler, - model, - add_line_numbers_to_hunks=True, - disable_extra_lines=False, - return_remaining_files=True,) - if isinstance(output, tuple): + chunking_enabled = get_settings().pr_reviewer.get("enable_large_pr_chunking", False) + diff_kwargs = { + "add_line_numbers_to_hunks": True, + "disable_extra_lines": False, + "return_remaining_files": True, + } + if callable(output_token_reserve): + diff_kwargs["output_token_reserve"] = output_token_reserve + if chunking_enabled: + diff_kwargs["return_prepared"] = True + output = get_pr_diff(self.git_provider, self.token_handler, model, **diff_kwargs) + if isinstance(output, PreparedPRDiff): + self.patches_diff = output.diff + self.remaining_files_list = output.remaining_files_list + elif isinstance(output, tuple): self.patches_diff, self.remaining_files_list = output else: self.patches_diff = output self.remaining_files_list = [] - # a non-empty remaining_files_list means the token budget truncated the diff - if self.remaining_files_list and get_settings().pr_reviewer.get("enable_large_pr_chunking", False): - if await self._prepare_chunked_prediction(model): + # Resume an incomplete chunk plan even when a fallback model can fit the full diff. + # Otherwise the single-call path would bypass cached successful chunks. + has_incomplete_chunk_plan = hasattr(self, "_chunked_patches_diff_list") + if chunking_enabled and (self.remaining_files_list or has_incomplete_chunk_plan): + prepared_diff = output if isinstance(output, PreparedPRDiff) else None + if await self._prepare_chunked_prediction(model, prepared_diff): return if self.patches_diff: get_logger().debug("PR diff", diff=self.patches_diff) - self.prediction = await self._get_prediction(model) + prediction = await self._get_prediction(model) + self._load_valid_review_yaml(prediction) + self.prediction = prediction else: get_logger().warning(f"Empty diff for PR: {self.pr_url}") - self.prediction = None + raise ValueError(f"No PR diff fits the /review request for {model}") - async def _prepare_chunked_prediction(self, model: str) -> bool: + async def _prepare_chunked_prediction(self, model: str, + prepared_diff: PreparedPRDiff | None = None) -> bool: """Review a too-large diff in chunks and merge the per-chunk verdicts. Returns False when chunking does not apply, leaving the single-call flow in place. """ - patches_diff_list, remaining_files_list = get_pr_multi_diffs( - self.git_provider, - self.token_handler, - model, - max_calls=get_settings().pr_reviewer.get("max_number_of_calls", 3), - add_line_numbers=True, - return_remaining_files=True) + patches_diff_list = getattr(self, "_chunked_patches_diff_list", None) + if patches_diff_list is not None: + self._resize_pending_review_chunks(model) + patches_diff_list = self._chunked_patches_diff_list + if patches_diff_list is not None and self._chunked_remaining_files_list: + self._include_newly_reviewable_files(model) + if patches_diff_list is None: + multi_diff_kwargs = { + "max_calls": get_settings().pr_reviewer.get("max_number_of_calls", 3), + "add_line_numbers": True, + "return_remaining_files": True, + } + output_token_reserve = getattr( + getattr(self, "ai_handler", None), "get_output_token_reserve", None + ) + if callable(output_token_reserve): + multi_diff_kwargs["output_token_reserve"] = output_token_reserve + if prepared_diff is not None: + multi_diff_kwargs["prepared_diff"] = prepared_diff + patches_diff_list, remaining_files_list = get_pr_multi_diffs( + self.git_provider, + self.token_handler, + model, + **multi_diff_kwargs) + self._chunked_patches_diff_list = patches_diff_list + self._chunked_remaining_files_list = remaining_files_list + self._chunked_primary_model = model if len(patches_diff_list) < 2: get_logger().info("Large-diff chunking produced a single chunk, reviewing the PR in one call") return False get_logger().info(f"Number of PR chunk calls: {len(patches_diff_list)}") get_logger().debug("PR diff chunks", artifact=patches_diff_list) + chunk_results = getattr(self, "_chunked_results", {}) + pending_indices = [index for index in range(len(patches_diff_list)) if index not in chunk_results] predictions = await asyncio.gather( - *[self._get_prediction(model, patches_diff) for patches_diff in patches_diff_list], + *[self._get_prediction(model, patches_diff_list[index]) for index in pending_indices], return_exceptions=True) - raw_predictions, chunk_outputs, chunk_errors = [], [], [] - for chunk_index, prediction in enumerate(predictions): + chunk_errors = [] + for chunk_index, prediction in zip(pending_indices, predictions, strict=True): if isinstance(prediction, Exception): chunk_errors.append(prediction) get_logger().warning(f"Failed to review chunk {chunk_index + 1}; retaining successful chunks", @@ -817,26 +901,121 @@ async def _prepare_chunked_prediction(self, model: str) -> bool: continue if isinstance(prediction, BaseException): raise prediction - data = self._load_review_yaml(prediction) - if not isinstance(data, dict) or not isinstance(data.get("review"), dict) or not data["review"]: - get_logger().warning(f"Failed to parse the review of chunk {chunk_index + 1}", - artifact={"data": data}) + try: + data = self._load_valid_review_yaml(prediction, source=f"review chunk {chunk_index + 1}") + except Exception as error: + chunk_errors.append(error) + get_logger().warning(f"Failed to parse review chunk {chunk_index + 1}; retrying it with fallback", + artifact={"error": error}) continue - raw_predictions.append(prediction) - chunk_outputs.append(data) + self._validate_review_schema(data) + chunk_results[chunk_index] = (prediction, data, model) + self._chunked_results = chunk_results - if not chunk_outputs: + if len(chunk_results) < len(patches_diff_list): if chunk_errors: raise chunk_errors[0] - get_logger().warning("No chunk produced a parsable review, falling back to a single review call") + raise ValueError("No valid review output was produced for one or more chunks") + + return self._merge_cached_review_chunks() + + def _resize_pending_review_chunks(self, model: str) -> None: + """Split oversized pending chunks at file boundaries while preserving result order.""" + chunks = self._chunked_patches_diff_list + results = getattr(self, "_chunked_results", {}) + attempt_budget = self._review_attempt_budget(model) + chunk_limit = attempt_budget.available_tokens( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + preserve_minimum=True, + ) + max_calls = get_settings().pr_reviewer.get("max_number_of_calls", 3) + resized, retained = [], {} + for index, chunk in enumerate(chunks): + if index in results: + retained[len(resized)] = results[index] + resized.append(chunk) + continue + if attempt_budget.count_tokens(chunk) <= chunk_limit: + resized.append(chunk) + continue + sections = re.split(r"(?=^## File: ')", chunk, flags=re.MULTILINE) + parts, current = [], "" + for section in sections: + if current and attempt_budget.count_tokens(current + section) > chunk_limit: + parts.append(current) + current = "" + current += section + if current: + parts.append(current) + reserved = len(chunks) - index - 1 + if (parts and len(resized) + len(parts) + reserved <= max_calls + and all(attempt_budget.count_tokens(part) <= chunk_limit for part in parts)): + resized.extend(parts) + else: + # Retain unsplittable work for a later model and report it as failed if none can fit it. + resized.append(chunk) + self._chunked_patches_diff_list = resized + self._chunked_results = retained + + def _include_newly_reviewable_files(self, model: str) -> None: + """Add newly fitting files to pending work without resending successful chunks.""" + chunks = self._chunked_patches_diff_list + results = getattr(self, "_chunked_results", {}) + remaining = self._chunked_remaining_files_list + pending = [index for index in range(len(chunks)) if index not in results] + attempt_budget = self._review_attempt_budget(model) + chunk_limit = attempt_budget.available_tokens( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + preserve_minimum=True, + ) + max_calls = get_settings().pr_reviewer.get("max_number_of_calls", 3) + included = set() + for section in re.split(r"(?=^## File: ')", self.patches_diff or "", flags=re.MULTILINE): + match = re.match(r"## File: '(.*)'\n", section) + if not match or match[1] not in remaining or match[1] in included: + continue + for index in pending: + combined = chunks[index] + "\n\n" + section + if attempt_budget.count_tokens(combined) <= chunk_limit: + chunks[index] = combined + included.add(match[1]) + break + else: + if len(chunks) < max_calls and attempt_budget.count_tokens(section) <= chunk_limit: + pending.append(len(chunks)) + chunks.append(section) + included.add(match[1]) + self._chunked_remaining_files_list = [name for name in remaining if name not in included] + + def _review_attempt_budget(self, model: str) -> AttemptTokenBudget: + """Return the model-bound budget used for review chunk planning and dispatch.""" + return AttemptTokenBudget.for_attempt( + model, + self.token_handler, + output_token_reserve=getattr(self.ai_handler, "get_output_token_reserve", None), + ) + + def _merge_cached_review_chunks(self) -> bool: + """Merge successful chunks in order, retaining incomplete coverage after exhausted retries.""" + chunk_results = getattr(self, "_chunked_results", {}) + if not chunk_results: return False - # the raw text is kept for logging only; the merged verdict is in self.prediction_data + # Keep raw text for logging only; use the merged verdict from self.prediction_data. + indices = sorted(chunk_results) + raw_predictions = [chunk_results[index][0] for index in indices] + chunk_outputs = [chunk_results[index][1] for index in indices] self.prediction = "\n".join(raw_predictions) self.prediction_data = merge_review_chunks(chunk_outputs) - self.review_chunk_count = len(patches_diff_list) - self.review_failed_chunk_count = len(patches_diff_list) - len(chunk_outputs) - self.remaining_files_list = remaining_files_list + self.review_chunk_count = len(self._chunked_patches_diff_list) + self.review_failed_chunk_count = self.review_chunk_count - len(chunk_results) + self.remaining_files_list = self._chunked_remaining_files_list + models = list(dict.fromkeys(chunk_results[index][2] for index in indices)) + details = get_run_details() + if details is not None: + details.models_used = models + for model in models: + record_model_used(model, is_fallback=model != self._chunked_primary_model) return True async def _get_prediction(self, model: str, patches_diff: Optional[str] = None) -> str: @@ -852,17 +1031,26 @@ async def _get_prediction(self, model: str, patches_diff: Optional[str] = None) A string representing the AI prediction for the pull request review. """ variables = copy.deepcopy(self.vars) - variables["diff"] = self.patches_diff if patches_diff is None else patches_diff # update diff - - environment = Environment(undefined=StrictUndefined) - system_prompt = environment.from_string(get_settings().pr_review_prompt.system).render(variables) - user_prompt = environment.from_string(get_settings().pr_review_prompt.user).render(variables) + patches_diff = self.patches_diff if patches_diff is None else patches_diff + budget = self._review_attempt_budget(model) + fitted = budget.fit_prompt_variable( + variables, + "diff", + patches_diff, + ai_handler=self.ai_handler, + default_output_tokens=OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, + preserve_minimum=True, + ) + if fitted.optional_text != patches_diff: + raise ValueError( + f"The complete packed review diff does not fit the token limit for {model}" + ) response, finish_reason = await self.ai_handler.chat_completion( model=model, temperature=get_settings().config.temperature, - system=system_prompt, - user=user_prompt + system=fitted.system_prompt, + user=fitted.user_prompt, ) return response @@ -873,7 +1061,58 @@ def _load_review_yaml(prediction: str) -> dict: keys_fix_yaml=["ticket_compliance_check", "estimated_effort_to_review_[1-5]:", "risk_level:", "merge_recommendation:", "security_concerns:", "key_issues_to_review:", "relevant_file:", "relevant_line:", "suggestion:"], - first_key='review', last_key='security_concerns') + first_key='review', last_key='security_concerns') + + def _validate_review_schema(self, data: object) -> bool: + try: + PRReview.model_validate(data) + except ValidationError as error: + first_error = error.errors()[0] + field_path = ".".join(str(part) for part in first_error.get("loc", ())) or "$" + value = None if first_error.get("type") == "missing" else first_error.get("input") + get_logger().warning( + "Review output failed schema validation", + artifact={ + "field": field_path, + "value": value, + }, + ) + return False + + if isinstance(data, dict) and isinstance(data.get("review"), dict): + review = data["review"] + required_fields = ( + ("ticket_compliance_check", "related_tickets"), + ("estimated_effort_to_review_[1-5]", "require_estimate_effort_to_review"), + ("risk_level", "require_risk_assessment"), + ("merge_recommendation", "require_merge_recommendation"), + ("review_priority_files", "require_priority_files"), + ("contribution_time_cost_estimate", "require_estimate_contribution_time_cost"), + ("score", "require_score"), + ("relevant_tests", "require_tests"), + ("insights_from_user_answers", "question_str"), + ("security_concerns", "require_security_review"), + ("todo_sections", "require_todo_scan"), + ("can_be_split", "require_can_be_split_review"), + ) + vars_ = getattr(self, "vars", {}) + for field_name, setting_name in required_fields: + if not vars_.get(setting_name) or field_name in review and review[field_name] is not None: + continue + get_logger().warning( + "Review output failed schema validation", + artifact={"field": f"review.{field_name}", "value": None}, + ) + return False + return True + + @classmethod + def _load_valid_review_yaml(cls, prediction: str, *, source: str = "model response") -> dict: + """Parse one prediction and require the minimum publishable review shape.""" + data = cls._load_review_yaml(prediction) + if not isinstance(data, dict) or not isinstance(data.get("review"), dict) or not data["review"]: + raise ValueError(f"{source} did not contain a non-empty review mapping") + return data def _prepare_pr_review(self) -> str: """ @@ -881,6 +1120,8 @@ def _prepare_pr_review(self) -> str: the feedback. """ data = self.prediction_data if self.prediction_data is not None else self._load_review_yaml(self.prediction) + if self.prediction_data is None: + self._validate_review_schema(data) github_action_output(data, 'review') if not isinstance(data, dict) or not isinstance(data.get('review'), dict) or not data['review']: @@ -1007,7 +1248,7 @@ def _prepare_pr_review(self) -> str: # Add custom labels from the review prediction (effort, security) self.set_review_labels(data) - if markdown_text == None or len(markdown_text) == 0: + if markdown_text is None or len(markdown_text) == 0: markdown_text = "" return markdown_text @@ -1061,7 +1302,7 @@ def _published_inline_key_issue_fingerprints(self, store: InlineCommentStore, store.add_body(body) except Exception as e: get_logger().warning( - f"Inline key-issue publishing cannot verify new Azure DevOps threads, error: {e}; " + f"Inline key-issue publishing cannot verify newly published comments, error: {e}; " "keeping findings in the review summary") return set() return {fingerprint for fingerprint in fingerprints if store.seen(fingerprint)} @@ -1085,7 +1326,7 @@ def _publish_key_issues_as_inline_comments(self, data: dict) -> dict: store = get_inline_comment_store(self.git_provider) store.load() if store.load_failed: - get_logger().warning("Inline key-issue publishing cannot verify existing Azure DevOps threads; " + get_logger().warning("Inline key-issue publishing cannot verify existing provider comments; " "keeping findings in the review summary") return data remaining_issues = [] @@ -1108,9 +1349,15 @@ def _publish_key_issues_as_inline_comments(self, data: dict) -> dict: if location_fingerprint in candidate_comments: candidate_issues[location_fingerprint].append(issue) continue + max_chars = next( + (getattr(self.git_provider, attr) for attr in + ("max_comment_chars", "max_comment_length") + if isinstance(getattr(self.git_provider, attr, None), int)), + None, + ) comment["body"] = key_issue_body_with_markers( comment["body"], fingerprint, location_fingerprint, - getattr(self.git_provider, "max_comment_chars", None)) + max_chars, self.git_provider) candidate_comments[location_fingerprint] = comment candidate_issues[location_fingerprint] = [issue] candidate_fingerprints[location_fingerprint] = fingerprint @@ -1128,7 +1375,7 @@ def _publish_key_issues_as_inline_comments(self, data: dict) -> dict: "end_line": comment["relevant_lines_end"]} for comment in candidate_comments.values()] get_logger().warning( - f"Failed to publish review findings as Azure DevOps threads, error: {e}", + f"Failed to publish review findings as inline comments, error: {e}", artifact={"locations": locations}) verified_locations = self._published_inline_key_issue_fingerprints(store, set(candidate_comments)) for location_fingerprint, comment in candidate_comments.items(): @@ -1138,7 +1385,7 @@ def _publish_key_issues_as_inline_comments(self, data: dict) -> dict: store.add(location_fingerprint) published += len(issues_for_location) continue - get_logger().warning("Failed to publish a review finding as an Azure DevOps inline comment, " + get_logger().warning("Failed to publish a review finding as an inline comment, " "keeping it in the summary", artifact={"relevant_file": comment["relevant_file"], "start_line": comment["relevant_lines_start"], @@ -1166,7 +1413,9 @@ def _get_user_answers(self) -> Tuple[str, str]: question_str = "" answer_str = "" - if self.is_answer: + if self.is_answer and self.git_provider.is_supported("get_issue_comments"): + # __init__ already raises when is_answer is True and this is unsupported, so + # this repeats that check locally rather than relying on the caller for it. discussion_messages = self.git_provider.get_issue_comments() # providers return the comments oldest-first. PyGithub's PaginatedList reverses lazily, @@ -1186,29 +1435,6 @@ def _get_user_answers(self) -> Tuple[str, str]: return question_str, answer_str - def _get_previous_review_comment(self): - """ - Get the previous review comment if it exists. - """ - try: - if hasattr(self.git_provider, "get_previous_review"): - return self.git_provider.get_previous_review( - full=not self.incremental.is_incremental, - incremental=self.incremental.is_incremental, - ) - except Exception as e: - get_logger().exception(f"Failed to get previous review comment, error: {e}") - - def _remove_previous_review_comment(self, comment): - """ - Remove the previous review comment if it exists. - """ - try: - if comment: - self.git_provider.remove_comment(comment) - except Exception as e: - get_logger().exception(f"Failed to remove previous review comment, error: {e}") - def _can_run_incremental_review(self) -> bool: """ Checks if we can run incremental review according the various configurations and previous review. @@ -1233,12 +1459,17 @@ def _can_run_incremental_review(self) -> bool: num_commits_threshold = get_settings().pr_reviewer.minimal_commits_for_incremental_review not_enough_commits = num_new_commits < num_commits_threshold # checking if the commits are not too recent to start the review - recent_commits_threshold = datetime.datetime.now() - datetime.timedelta( + recent_commits_threshold = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta( minutes=get_settings().pr_reviewer.minimal_minutes_for_incremental_review ) last_seen_commit_date = ( self.incremental.last_seen_commit.commit.author.date if self.incremental.last_seen_commit else None ) + # PyGithub returns timezone-aware UTC commit dates; the threshold below is a + # naive datetime. Normalize to naive UTC so the comparison cannot raise + # TypeError, matching how the GitLab and Azure providers emit commit dates. + if last_seen_commit_date is not None and last_seen_commit_date.tzinfo is not None: + last_seen_commit_date = last_seen_commit_date.astimezone(datetime.timezone.utc).replace(tzinfo=None) all_commits_too_recent = ( last_seen_commit_date > recent_commits_threshold if self.incremental.last_seen_commit else False ) @@ -1267,6 +1498,7 @@ def set_review_labels(self, data): self.git_provider.is_supported("get_labels")): try: review_labels = [] + has_valid_security_verdict = False if get_settings().pr_reviewer.enable_review_labels_effort: estimated_effort = data['review']['estimated_effort_to_review_[1-5]'] estimated_effort_number = None @@ -1283,10 +1515,13 @@ def set_review_labels(self, data): estimated_effort_number = max(1, min(5, int(estimated_effort_number))) review_labels.append(f'Review effort {estimated_effort_number}/5') if get_settings().pr_reviewer.enable_review_labels_security and get_settings().pr_reviewer.require_security_review: - security_concerns = data['review']['security_concerns'] # yes, because ... - security_concerns_bool = 'yes' in security_concerns.lower() or 'true' in security_concerns.lower() - if security_concerns_bool: - review_labels.append('Possible security concern') + security_concerns = data['review'].get('security_concerns') + if security_concerns is None: + get_logger().warning("Missing security_concerns in review data") + else: + has_valid_security_verdict = True + if not is_value_no(security_concerns): + review_labels.append('Possible security concern') current_labels = self.git_provider.get_pr_labels(update=True) if not current_labels: @@ -1294,8 +1529,9 @@ def set_review_labels(self, data): get_logger().debug(f"Current labels:\n{current_labels}") if current_labels: current_labels_filtered = [label for label in current_labels if - not label.lower().startswith('review effort') and not label.lower().startswith( - 'possible security concern')] + (not label.lower().startswith('review effort') and + not (label.lower().startswith( + 'possible security concern') and has_valid_security_verdict))] else: current_labels_filtered = [] new_labels = review_labels + current_labels_filtered @@ -1319,4 +1555,4 @@ def auto_approve_logic(self): else: get_logger().info("Auto-approval option is disabled") self.git_provider.publish_comment("Auto-approval option for PR-Agent is disabled. " - "You can enable it via a [configuration file](https://github.com/Codium-ai/pr-agent/blob/main/docs/REVIEW.md#auto-approval-1)") + "You can enable it via a [configuration file](https://docs.pr-agent.ai/usage-guide/configuration_reference/#config)") diff --git a/pr_agent/tools/pr_similar_issue.py b/pr_agent/tools/pr_similar_issue.py index c952628384..a7bda0079c 100644 --- a/pr_agent/tools/pr_similar_issue.py +++ b/pr_agent/tools/pr_similar_issue.py @@ -1,3 +1,4 @@ +import hashlib import time from enum import Enum from typing import List @@ -6,13 +7,15 @@ from pydantic import BaseModel, Field from pr_agent.algo import MAX_TOKENS +from pr_agent.algo.token_budget import get_max_tokens from pr_agent.algo.token_handler import TokenHandler -from pr_agent.algo.utils import get_max_tokens from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider from pr_agent.log import get_logger MODEL = "text-embedding-ada-002" +PINECONE_UPSERT_READY_TIMEOUT_SECONDS = 30 +PINECONE_UPSERT_READY_POLL_SECONDS = 0.5 _EMBEDDING_CLIENTS = {} @@ -67,10 +70,116 @@ def _qdrant_collection_name(base_name: str) -> str: return f"{base_name}-v2" +def _lancedb_similar_search(table, query_vector, repo_name_for_index): + """Search a lancedb table with the same cosine metric and five-hit limit as the other backends. + + Pinecone and qdrant both return cosine similarity and request five hits; lancedb's default + squared-L2 metric would otherwise make ``1 - _distance`` meaningless. Cosine distance keeps the + printed score (``1 - _distance``) equal to the cosine similarity reported elsewhere. + """ + return ( + table.search(query_vector) + .distance_type("cosine") + .limit(5) + .where(f"metadata.repo='{repo_name_for_index}'", prefilter=True) + .to_list() + ) + + +def _pinecone_namespace(repo_full_name: str) -> str: + """Return a collision-resistant Pinecone namespace for a canonical repository name.""" + return f"repo-{hashlib.sha256(repo_full_name.lower().encode()).hexdigest()}" + + +def _raise_on_pinecone_upsert_errors(response): + """Raise when Pinecone reports asynchronous batch failures in the upsert response.""" + response_dict = {} + if isinstance(response, dict): + response_dict = response + elif hasattr(response, "to_dict"): + response_dict = response.to_dict() + + failed_item_count = getattr(response, "failed_item_count", response_dict.get("failed_item_count")) + has_errors = getattr(response, "has_errors", response_dict.get("has_errors", False)) + if not has_errors and not failed_item_count: + return + + errors = getattr(response, "errors", response_dict.get("errors")) or [] + get_logger().error( + "Pinecone upsert failed", + artifact={ + "failed_item_count": failed_item_count, + "errors": [_get_value(error, "error_message") for error in errors], + }, + ) + raise RuntimeError(f"Pinecone upsert failed for {failed_item_count} vectors") + + +def _get_value(source, key, default=None): + if source is None: + return default + if isinstance(source, dict): + return source.get(key, default) + return getattr(source, key, default) + + +def _pinecone_response_info(response): + response_info = _get_value(response, "response_info") + if response_info is None and hasattr(response, "to_dict"): + response_info = response.to_dict().get("response_info") + return response_info + + +def _pinecone_lsn_committed(response): + response_info = _pinecone_response_info(response) + return _get_value(response_info, "lsn_committed") + + +def _pinecone_response_is_reconciled(response, target_lsn): + response_info = _pinecone_response_info(response) + if response_info is None: + return False + if hasattr(response_info, "is_reconciled"): + return response_info.is_reconciled(target_lsn) + lsn_reconciled = _get_value(response_info, "lsn_reconciled") + return lsn_reconciled is not None and lsn_reconciled >= target_lsn + + +def _wait_for_pinecone_upsert_readiness(pinecone_index, upsert_response, namespace, vector_id): + target_lsn = _pinecone_lsn_committed(upsert_response) + if target_lsn is None: + get_logger().warning("Pinecone upsert response did not include an LSN; skipping readiness wait") + return + + deadline = time.monotonic() + PINECONE_UPSERT_READY_TIMEOUT_SECONDS + while True: + if time.monotonic() >= deadline: + raise TimeoutError(f"Pinecone upsert was not query-ready after {PINECONE_UPSERT_READY_TIMEOUT_SECONDS}s") + fetch_response = pinecone_index.fetch(ids=[vector_id], namespace=namespace) + if _pinecone_response_is_reconciled(fetch_response, target_lsn): + return + time.sleep(PINECONE_UPSERT_READY_POLL_SECONDS) + + +def _provider_supports_issue_indexing() -> bool: + """Whether the configured provider can back `/similar_issue`. + + The check is on the provider class rather than on the configured id, so a provider + registered through `register_git_provider()` is judged by the capability it declares. + An unresolvable configuration is reported as unsupported, which is what the tool's + `run()` already handles, rather than raised out of `__init__`. + """ + try: + provider_class = get_git_provider() + except ValueError: + return False + return provider_class.supports_issue_indexing() + + class PRSimilarIssue: def __init__(self, issue_url: str, ai_handler, args: list = None): self.issue_url = issue_url - self.supported = get_settings().config.git_provider == "github" + self.supported = _provider_supports_issue_indexing() if not self.supported: return @@ -83,44 +192,45 @@ def __init__(self, issue_url: str, ai_handler, args: list = None): self.token_handler = TokenHandler() repo_obj = self.git_provider.repo_obj repo_name_for_index = self.repo_name_for_index = repo_obj.full_name.lower().replace('/', '-').replace('_/', '-') + self.pinecone_namespace = _pinecone_namespace(repo_obj.full_name) index_name = self.index_name = "codium-ai-pr-agent-issues" if get_settings().pr_similar_issue.vectordb == "pinecone": try: import pinecone - except: - raise Exception("Please install 'pinecone' and 'pinecone_datasets' to use pinecone as vectordb") - # assuming pinecone api key and environment are set in secrets file + from pinecone import ServerlessSpec + except ImportError: + raise Exception("Please install the 'pinecone' package to use pinecone as vectordb") from None + # assuming pinecone api key, cloud and region are set in secrets file try: api_key = get_settings().pinecone.api_key - environment = get_settings().pinecone.environment + cloud = get_settings().pinecone.cloud + region = get_settings().pinecone.region except Exception: if not self.cli_mode: repo_name, original_issue_number = self.git_provider._parse_issue_url(self.issue_url.split('=')[-1]) issue_main = self.git_provider.repo_obj.get_issue(original_issue_number) - issue_main.create_comment("Please set pinecone api key and environment in secrets file") - raise Exception("Please set pinecone api key and environment in secrets file") + issue_main.create_comment("Please set pinecone api key, cloud and region in secrets file") + raise Exception("Please set pinecone api key, cloud and region in secrets file") + self.pc = pinecone.Pinecone(api_key=api_key) + self.pc_spec = ServerlessSpec(cloud=cloud, region=region) + self.pinecone_index = None # check if index exists, and if repo is already indexed run_from_scratch = False - if run_from_scratch: # for debugging - pinecone.init(api_key=api_key, environment=environment) - if index_name in pinecone.list_indexes(): - get_logger().info('Removing index...') - pinecone.delete_index(index_name) - get_logger().info('Done') - upsert = True - pinecone.init(api_key=api_key, environment=environment) - if index_name not in pinecone.list_indexes(): + if not self.pc.has_index(index_name): run_from_scratch = True upsert = False else: if get_settings().pr_similar_issue.force_update_dataset: upsert = True else: - pinecone_index = pinecone.Index(index_name=index_name) - res = pinecone_index.fetch([f"example_issue_{repo_name_for_index}"]).to_dict() + self.pinecone_index = self.pc.Index(name=index_name) + res = self.pinecone_index.fetch( + ids=[f"example_issue_{repo_name_for_index}"], + namespace=self.pinecone_namespace, + ).to_dict() if res["vectors"]: upsert = False @@ -130,33 +240,41 @@ def __init__(self, issue_url: str, ai_handler, args: list = None): get_logger().info('Getting issues...') issues = list(repo_obj.get_issues(state='all')) get_logger().info('Done') - self._update_index_with_issues(issues, repo_name_for_index, upsert=upsert) + self._update_index_with_issues( + issues, + repo_name_for_index, + pinecone_namespace=self.pinecone_namespace, + upsert=upsert, + ) else: # update index if needed - pinecone_index = pinecone.Index(index_name=index_name) + self.pinecone_index = self.pc.Index(name=index_name) issues_to_update = [] issues_paginated_list = repo_obj.get_issues(state='all') - counter = 1 for issue in issues_paginated_list: if issue.pull_request: continue issue_str, comments, number = self._process_issue(issue) issue_key = f"issue_{number}" id = issue_key + "." + "issue" - res = pinecone_index.fetch([id]).to_dict() + res = self.pinecone_index.fetch(ids=[id], namespace=self.pinecone_namespace).to_dict() is_new_issue = True for vector in res["vectors"].values(): if vector['metadata']['repo'] == repo_name_for_index: is_new_issue = False break if is_new_issue: - counter += 1 issues_to_update.append(issue) else: break if issues_to_update: - get_logger().info(f'Updating index with {counter} new issues...') - self._update_index_with_issues(issues_to_update, repo_name_for_index, upsert=True) + get_logger().info(f'Updating index with {len(issues_to_update)} new issues...') + self._update_index_with_issues( + issues_to_update, + repo_name_for_index, + pinecone_namespace=self.pinecone_namespace, + upsert=True, + ) else: get_logger().info('No new issues to update') @@ -169,25 +287,21 @@ def __init__(self, issue_url: str, ai_handler, args: list = None): self.table = None run_from_scratch = False - if run_from_scratch: # for debugging - if index_name in self.db.table_names(): - get_logger().info('Removing Table...') - self.db.drop_table(index_name) - get_logger().info('Done') - ingest = True - if index_name not in self.db.table_names(): + force_refresh = False + if not self._table_exists_in_db(index_name): run_from_scratch = True ingest = False else: if get_settings().pr_similar_issue.force_update_dataset: + force_refresh = True ingest = True else: self.table = self.db[index_name] - res = self.table.search().limit(len(self.table)).where(f"id='example_issue_{repo_name_for_index}'").to_list() - get_logger().info("result: ", res) - if res[0].get("vector"): + if self._lancedb_repo_already_indexed(repo_name_for_index): ingest = False + else: + force_refresh = True if run_from_scratch or ingest: # indexing the entire repo get_logger().info('Indexing the entire repo...') @@ -196,11 +310,10 @@ def __init__(self, issue_url: str, ai_handler, args: list = None): issues = list(repo_obj.get_issues(state='all')) get_logger().info('Done') - self._update_table_with_issues(issues, repo_name_for_index, ingest=ingest) + self._update_table_with_issues(issues, repo_name_for_index, ingest=ingest, force_refresh=force_refresh) else: # update table if needed issues_to_update = [] issues_paginated_list = repo_obj.get_issues(state='all') - counter = 1 for issue in issues_paginated_list: if issue.pull_request: continue @@ -214,13 +327,12 @@ def __init__(self, issue_url: str, ai_handler, args: list = None): is_new_issue = False break if is_new_issue: - counter += 1 issues_to_update.append(issue) else: break if issues_to_update: - get_logger().info(f'Updating index with {counter} new issues...') + get_logger().info(f'Updating index with {len(issues_to_update)} new issues...') self._update_table_with_issues(issues_to_update, repo_name_for_index, ingest=True) else: get_logger().info('No new issues to update') @@ -241,6 +353,9 @@ def __init__(self, issue_url: str, ai_handler, args: list = None): api_key = get_settings().qdrant.api_key url = get_settings().qdrant.url except Exception: + url = None + + if not url: if not self.cli_mode: repo_name, original_issue_number = self.git_provider._parse_issue_url(self.issue_url.split('=')[-1]) issue_main = self.git_provider.repo_obj.get_issue(original_issue_number) @@ -281,7 +396,6 @@ def __init__(self, issue_url: str, ai_handler, args: list = None): else: issues_to_update = [] issues_paginated_list = repo_obj.get_issues(state='all') - counter = 1 for issue in issues_paginated_list: if issue.pull_request: continue @@ -296,21 +410,30 @@ def __init__(self, issue_url: str, ai_handler, args: list = None): ]), ) if response.count == 0: - counter += 1 issues_to_update.append(issue) else: break if issues_to_update: - get_logger().info(f'Updating index with {counter} new issues...') + get_logger().info(f'Updating index with {len(issues_to_update)} new issues...') self._update_qdrant_with_issues(issues_to_update, repo_name_for_index, ingest=True) else: get_logger().info('No new issues to update') + @staticmethod + def _record_similar_hit(relevant_issues_number_list: list, relevant_comment_number_list: list, + score_list: list, issue_number: int, comment_number: int, score: float): + """Keep the first, best-scored hit per issue and keep the three lists aligned.""" + if issue_number in relevant_issues_number_list: + return + relevant_issues_number_list.append(issue_number) + relevant_comment_number_list.append(comment_number) + score_list.append(str("{:.2f}".format(score))) + async def run(self): if not self.supported: - message = "The /similar_issue tool is currently supported only for GitHub." + message = "The /similar_issue tool is not supported by the configured git provider." if get_settings().config.publish_output: try: from pr_agent.git_providers import get_git_provider_with_context @@ -323,6 +446,7 @@ async def run(self): artifact={"error": str(e)}, ) return "" + get_logger().info('Getting issue...') repo_name, original_issue_number = self.git_provider._parse_issue_url(self.issue_url.split('=')[-1]) issue_main = self.git_provider.repo_obj.get_issue(original_issue_number) @@ -337,12 +461,11 @@ async def run(self): score_list = [] if get_settings().pr_similar_issue.vectordb == "pinecone": - import pinecone - - pinecone_index = pinecone.Index(index_name=self.index_name) - res = pinecone_index.query(embeds[0], + pinecone_index = self.pc.Index(name=self.index_name) + res = pinecone_index.query(vector=embeds[0], top_k=5, filter={"repo": self.repo_name_for_index}, + namespace=self.pinecone_namespace, include_metadata=True).to_dict() for r in res['matches']: @@ -358,17 +481,13 @@ async def run(self): if original_issue_number == issue_number: continue - if issue_number not in relevant_issues_number_list: - relevant_issues_number_list.append(issue_number) - if 'comment' in r["id"]: - relevant_comment_number_list.append(int(r["id"].split('.')[1].split('_')[-1])) - else: - relevant_comment_number_list.append(-1) - score_list.append(str("{:.2f}".format(r['score']))) + comment_number = int(r["id"].split('.')[1].split('_')[-1]) if 'comment' in r["id"] else -1 + self._record_similar_hit(relevant_issues_number_list, relevant_comment_number_list, + score_list, issue_number, comment_number, r['score']) get_logger().info('Done') elif get_settings().pr_similar_issue.vectordb == "lancedb": - res = self.table.search(embeds[0]).where(f"metadata.repo='{self.repo_name_for_index}'", prefilter=True).to_list() + res = _lancedb_similar_search(self.table, embeds[0], self.repo_name_for_index) for r in res: # skip example issue @@ -383,14 +502,9 @@ async def run(self): if original_issue_number == issue_number: continue - if issue_number not in relevant_issues_number_list: - relevant_issues_number_list.append(issue_number) - - if 'comment' in r["id"]: - relevant_comment_number_list.append(int(r["id"].split('.')[1].split('_')[-1])) - else: - relevant_comment_number_list.append(-1) - score_list.append(str("{:.2f}".format(1-r['_distance']))) + comment_number = int(r["id"].split('.')[1].split('_')[-1]) if 'comment' in r["id"] else -1 + self._record_similar_hit(relevant_issues_number_list, relevant_comment_number_list, + score_list, issue_number, comment_number, 1 - r['_distance']) get_logger().info('Done') elif get_settings().pr_similar_issue.vectordb == "qdrant": @@ -414,13 +528,9 @@ async def run(self): continue if original_issue_number == issue_number: continue - if issue_number not in relevant_issues_number_list: - relevant_issues_number_list.append(issue_number) - if 'comment' in rid: - relevant_comment_number_list.append(int(rid.split('.')[1].split('_')[-1])) - else: - relevant_comment_number_list.append(-1) - score_list.append(str("{:.2f}".format(r.score))) + comment_number = int(rid.split('.')[1].split('_')[-1]) if 'comment' in rid else -1 + self._record_similar_hit(relevant_issues_number_list, relevant_comment_number_list, + score_list, issue_number, comment_number, r.score) get_logger().info('Done') get_logger().info('Publishing response...') @@ -434,9 +544,9 @@ async def run(self): url = list(issue.get_comments())[relevant_comment_number_list[i]].html_url similar_issues_str += f"{i + 1}. **[{title}]({url})** (score={score_list[i]})\n\n" if get_settings().config.publish_output: - response = issue_main.create_comment(similar_issues_str) + issue_main.create_comment(similar_issues_str) get_logger().info(similar_issues_str) - get_logger().info('Done') + get_logger().info("Done") def _process_issue(self, issue): header = issue.title @@ -449,10 +559,8 @@ def _process_issue(self, issue): issue_str = f"Issue Header: \"{header}\"\n\nIssue Body:\n{body}" return issue_str, comments, number - def _update_index_with_issues(self, issues_list, repo_name_for_index, upsert=False): + def _update_index_with_issues(self, issues_list, repo_name_for_index, pinecone_namespace, upsert=False): import pandas as pd - import pinecone - from pinecone_datasets import Dataset, DatasetMetadata get_logger().info('Processing issues...') corpus = Corpus() @@ -468,13 +576,14 @@ def _update_index_with_issues(self, issues_list, repo_name_for_index, upsert=Fal if issue.pull_request: continue - counter += 1 - if counter % 100 == 0: - get_logger().info(f"Scanned {counter} issues") if counter >= self.max_issues_to_scan: get_logger().info(f"Scanned {self.max_issues_to_scan} issues, stopping") break + counter += 1 + if counter % 100 == 0: + get_logger().info(f"Scanned {counter} issues") + issue_str, comments, number = self._process_issue(issue) issue_key = f"issue_{number}" username = issue.user.login @@ -493,8 +602,7 @@ def _update_index_with_issues(self, issues_list, repo_name_for_index, upsert=Fal if comments: for j, comment in enumerate(comments): comment_body = comment.body - num_words_comment = len(comment_body.split()) - if num_words_comment < 10 or not isinstance(comment_body, str): + if not isinstance(comment_body, str) or len(comment_body.split()) < 10: continue if len(comment_body) < 8000 or \ @@ -512,55 +620,68 @@ def _update_index_with_issues(self, issues_list, repo_name_for_index, upsert=Fal get_logger().info('Done') get_logger().info('Embedding...') - list_to_encode = list(df["text"].values) + list_to_encode = df["text"].to_list() embeds = _embed_with_fallback(list_to_encode) df["values"] = embeds - meta = DatasetMetadata.empty() - meta.dense_model.dimension = len(embeds[0]) - ds = Dataset.from_pandas(df, meta) get_logger().info('Done') - api_key = get_settings().pinecone.api_key - environment = get_settings().pinecone.environment + vectors = [ + (row["id"], row["values"], row["metadata"]) + for row in df.to_dict(orient="records") + ] if not upsert: get_logger().info('Creating index from scratch...') - ds.to_pinecone_index(self.index_name, api_key=api_key, environment=environment) - time.sleep(15) # wait for pinecone to finalize indexing before querying - else: - get_logger().info('Upserting index...') - namespace = "" - batch_size: int = 100 - concurrency: int = 10 - pinecone.init(api_key=api_key, environment=environment) - ds._upsert_to_index(self.index_name, namespace, batch_size, concurrency) - time.sleep(5) # wait for pinecone to finalize upserting before querying + self.pc.create_index(name=self.index_name, dimension=len(embeds[0]), metric="cosine", spec=self.pc_spec, + timeout=120) + get_logger().info('Upserting index...') + self.pinecone_index = self.pc.Index(name=self.index_name) + upsert_response = self.pinecone_index.upsert(vectors=vectors, + namespace=pinecone_namespace, + batch_size=100, + max_concurrency=10) + _raise_on_pinecone_upsert_errors(upsert_response) + _wait_for_pinecone_upsert_readiness(self.pinecone_index, upsert_response, + namespace=pinecone_namespace, vector_id=vectors[0][0]) get_logger().info('Done') - def _update_table_with_issues(self, issues_list, repo_name_for_index, ingest=False): + def _table_exists_in_db(self, index_name) -> bool: + return index_name in self.db.list_tables().tables + + def _lancedb_repo_already_indexed(self, repo_name_for_index) -> bool: + """Check whether the shared lancedb table already holds this repo's sentinel row. + + One sentinel row per repository is written on the first full ingest, so the row's + absence on an existing table means this repository has never been indexed and must + go through the full ingest path to join the table. + """ + res = self.table.search().limit(len(self.table)).where( + f"id='example_issue_{repo_name_for_index}'" + ).to_list() + get_logger().info("result: ", res) + return bool(res) + + def _update_table_with_issues(self, issues_list, repo_name_for_index, ingest=False, + force_refresh=False): import pandas as pd get_logger().info('Processing issues...') corpus = Corpus() - example_issue_record = Record( - id=f"example_issue_{repo_name_for_index}", - text="example_issue", - metadata=Metadata(repo=repo_name_for_index) - ) - corpus.append(example_issue_record) + sentinel_id = f"example_issue_{repo_name_for_index}" counter = 0 for issue in issues_list: if issue.pull_request: continue - counter += 1 - if counter % 100 == 0: - get_logger().info(f"Scanned {counter} issues") if counter >= self.max_issues_to_scan: get_logger().info(f"Scanned {self.max_issues_to_scan} issues, stopping") break + counter += 1 + if counter % 100 == 0: + get_logger().info(f"Scanned {counter} issues") + issue_str, comments, number = self._process_issue(issue) issue_key = f"issue_{number}" username = issue.user.login @@ -579,8 +700,7 @@ def _update_table_with_issues(self, issues_list, repo_name_for_index, ingest=Fal if comments: for j, comment in enumerate(comments): comment_body = comment.body - num_words_comment = len(comment_body.split()) - if num_words_comment < 10 or not isinstance(comment_body, str): + if not isinstance(comment_body, str) or len(comment_body.split()) < 10: continue if len(comment_body) < 8000 or \ @@ -594,11 +714,33 @@ def _update_table_with_issues(self, issues_list, repo_name_for_index, ingest=Fal level=IssueLevel.COMMENT) ) corpus.append(comment_record) + + if len(corpus.documents) == 0: + if ingest and not force_refresh: + get_logger().info('No issues to index, skipping update') + return + # From-scratch runs and forced refreshes still carry the sentinel row, so the + # table keeps a searchable marker and the subsequent query path has an index. + corpus.append(Record(id=sentinel_id, text="example_issue", + metadata=Metadata(repo=repo_name_for_index))) + else: + # lancedb add() does not de-duplicate rows, so only carry the sentinel row when + # the table will not already have one after this write. + add_sentinel = not ingest or force_refresh + if ingest and self._table_exists_in_db(self.index_name): + if self.table is None: + self.table = self.db[self.index_name] + if not force_refresh: + add_sentinel = not self.table.search().limit(1).where(f"id='{sentinel_id}'").to_list() + if add_sentinel: + corpus.append(Record(id=sentinel_id, text="example_issue", + metadata=Metadata(repo=repo_name_for_index))) + df = pd.DataFrame(corpus.model_dump()["documents"]) get_logger().info('Done') get_logger().info('Embedding...') - list_to_encode = list(df["text"].values) + list_to_encode = df["text"].to_list() embeds = _embed_with_fallback(list_to_encode) df["vector"] = embeds get_logger().info('Done') @@ -606,14 +748,16 @@ def _update_table_with_issues(self, issues_list, repo_name_for_index, ingest=Fal if not ingest: get_logger().info('Creating table from scratch...') self.table = self.db.create_table(self.index_name, data=df, mode="overwrite") - time.sleep(15) else: get_logger().info('Ingesting in Table...') - if self.index_name not in self.db.table_names(): + if self._table_exists_in_db(self.index_name): + if self.table is None: + self.table = self.db[self.index_name] + if force_refresh: + self.table.delete(f"metadata.repo='{repo_name_for_index}'") self.table.add(df) else: get_logger().info(f"Table {self.index_name} doesn't exists!") - time.sleep(5) get_logger().info('Done') @@ -640,13 +784,14 @@ def _update_qdrant_with_issues(self, issues_list, repo_name_for_index, ingest=Fa if issue.pull_request: continue - counter += 1 - if counter % 100 == 0: - get_logger().info(f"Scanned {counter} issues") if counter >= self.max_issues_to_scan: get_logger().info(f"Scanned {self.max_issues_to_scan} issues, stopping") break + counter += 1 + if counter % 100 == 0: + get_logger().info(f"Scanned {counter} issues") + issue_str, comments, number = self._process_issue(issue) issue_key = f"issue_{number}" username = issue.user.login @@ -665,8 +810,7 @@ def _update_qdrant_with_issues(self, issues_list, repo_name_for_index, ingest=Fa if comments: for j, comment in enumerate(comments): comment_body = comment.body - num_words_comment = len(comment_body.split()) - if num_words_comment < 10 or not isinstance(comment_body, str): + if not isinstance(comment_body, str) or len(comment_body.split()) < 10: continue if len(comment_body) < 8000 or \ @@ -685,7 +829,7 @@ def _update_qdrant_with_issues(self, issues_list, repo_name_for_index, ingest=Fa get_logger().info('Done') get_logger().info('Embedding...') - list_to_encode = list(df["text"].values) + list_to_encode = df["text"].to_list() embeds = _embed_with_fallback(list_to_encode) df["vector"] = embeds get_logger().info('Done') @@ -708,7 +852,12 @@ def _update_qdrant_with_issues(self, issues_list, repo_name_for_index, ingest=Fa }, ) ) - self.qdrant.upsert(collection_name=self.qdrant_collection_name, points=points) + self.qdrant.upload_points( + collection_name=self.qdrant_collection_name, + points=points, + batch_size=100, + wait=True, + ) get_logger().info('Done') diff --git a/pr_agent/tools/pr_update_changelog.py b/pr_agent/tools/pr_update_changelog.py index 016b659856..2a4ed65756 100644 --- a/pr_agent/tools/pr_update_changelog.py +++ b/pr_agent/tools/pr_update_changelog.py @@ -1,17 +1,22 @@ +import asyncio import copy import re from datetime import date from functools import partial -from time import sleep from typing import Tuple -from jinja2 import Environment, StrictUndefined - from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler -from pr_agent.algo.pr_processing import get_pr_diff, retry_with_fallback_models +from pr_agent.algo.pr_processing import ( + OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + get_pr_diff, + retry_with_fallback_models, +) +from pr_agent.algo.run_output import show_relevant_configurations +from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler -from pr_agent.algo.utils import ModelType, show_relevant_configurations +from pr_agent.algo.utils import ModelType from pr_agent.config_loader import get_settings from pr_agent.git_providers import get_git_provider from pr_agent.git_providers.git_provider import get_main_pr_language @@ -58,34 +63,46 @@ def __init__(self, pr_url: str, cli_mode=False, args=None, ai_handler: partial[B self.main_language = get_main_pr_language( self.git_provider.get_languages(), self.git_provider.get_files() ) + self.changelog_read_error = None self._get_changelog_file() # self.changelog_file_str - self.ai_handler = ai_handler() - if self.main_language: - self.ai_handler.main_pr_language = self.main_language - - self.patches_diff = None - self.prediction = None - self.cli_mode = cli_mode - self.vars = { - "title": self.git_provider.pr.title, - "branch": self.git_provider.get_pr_branch(), - "description": self.git_provider.get_pr_description(), - "language": self.main_language, - "diff": "", # empty diff for initial calculation - "pr_link": "", - "changelog_file_str": self.changelog_file_str, - "today": date.today(), - "extra_instructions": get_settings().pr_update_changelog.extra_instructions, - "commit_messages_str": self.git_provider.get_commit_messages(), - } - self.token_handler = TokenHandler(self.git_provider.pr, - self.vars, - get_settings().pr_update_changelog_prompt.system, - get_settings().pr_update_changelog_prompt.user) + try: + self.ai_handler = ai_handler() + if self.main_language: + self.ai_handler.main_pr_language = self.main_language + + self.patches_diff = None + self.prediction = None + self.cli_mode = cli_mode + self.vars = { + "title": self.git_provider.pr.title, + "branch": self.git_provider.get_pr_branch(), + "description": self.git_provider.get_pr_description(), + "language": self.main_language, + "diff": "", # empty diff for initial calculation + "pr_link": "", + "changelog_file_str": self.changelog_file_str, + "today": date.today(), + "extra_instructions": get_settings().pr_update_changelog.extra_instructions, + "commit_messages_str": self.git_provider.get_commit_messages(), + } + self.token_handler = TokenHandler(self.git_provider.pr, + self.vars, + get_settings().pr_update_changelog_prompt.system, + get_settings().pr_update_changelog_prompt.user) + except Exception as setup_error: + changelog_read_error = self.changelog_read_error + if changelog_read_error is None: + raise + get_logger().exception( + f"Failed to initialize changelog generation after a read error: {setup_error}" + ) + self._publish_changelog_read_error_fallback() + raise changelog_read_error async def run(self): get_logger().info('Updating the changelog...') + changelog_read_error = getattr(self, "changelog_read_error", None) # If a push was requested but isn't possible (unsupported provider or restricted_mode), # the changelog is still generated and published as a comment below (commit_changelog is @@ -96,49 +113,139 @@ async def run(self): f"publishing the changelog as a comment instead" ) + temporary_comment_published = False if get_settings().config.publish_output: - self.git_provider.publish_comment("Preparing changelog updates...", is_temporary=True) - - await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.WEAK) - - new_file_content, answer = self._prepare_changelog_update() - - # Output the relevant configurations if enabled - if get_settings().get('config', {}).get('output_relevant_configurations', False): - answer += show_relevant_configurations(relevant_section='pr_update_changelog') - - get_logger().debug("PR output", artifact=answer) + try: + self.git_provider.publish_comment("Preparing changelog updates...", is_temporary=True) + temporary_comment_published = True + except Exception as progress_error: + if changelog_read_error is None: + raise + get_logger().exception( + f"Failed to publish changelog progress after a read error: {progress_error}" + ) - if get_settings().config.publish_output: - self.git_provider.remove_initial_comment() - if self.commit_changelog: - self._push_changelog_update(new_file_content, answer) - else: - changelog_comment = f"**Changelog updates:** 🔄\n\n{answer}" - if self.push_skipped_reason: - changelog_comment += ( - f"\n\n> ℹ️ These changes were not pushed to the repository " - f"({self.push_skipped_reason})." + try: + try: + await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.WEAK) + except Exception as generation_error: + if changelog_read_error is None: + raise + get_logger().exception( + f"Failed to generate changelog fallback after a read error: {generation_error}" + ) + self._publish_changelog_read_error_fallback() + raise changelog_read_error + + new_file_content, answer = self._prepare_changelog_update() + + if changelog_read_error is not None: + self._publish_changelog_read_error_fallback(answer) + raise changelog_read_error + + # Output the relevant configurations if enabled + if get_settings().get('config', {}).get('output_relevant_configurations', False): + answer += show_relevant_configurations(relevant_section='pr_update_changelog') + + get_logger().debug("PR output", artifact=answer) + + if get_settings().config.publish_output: + if self.commit_changelog: + await self._push_changelog_update(new_file_content, answer) + else: + changelog_comment = f"**Changelog updates:** 🔄\n\n{answer}" + if self.push_skipped_reason: + changelog_comment += ( + f"\n\n> ℹ️ These changes were not pushed to the repository " + f"({self.push_skipped_reason})." + ) + self.git_provider.publish_comment(changelog_comment) + finally: + if temporary_comment_published: + try: + self.git_provider.remove_initial_comment() + except Exception as cleanup_error: + get_logger().warning( + f"Failed to remove the temporary changelog comment: {cleanup_error}" ) - self.git_provider.publish_comment(changelog_comment) - async def _prepare_prediction(self, model: str): - self.patches_diff = get_pr_diff(self.git_provider, self.token_handler, model) - if self.patches_diff: - get_logger().debug("PR diff", artifact=self.patches_diff) - self.prediction = await self._get_prediction(model) + def _publish_changelog_read_error_fallback(self, answer: str = ""): + if answer: + changelog_comment = f"**Changelog updates:** 🔄\n\n{answer}" else: - get_logger().error("Error getting PR diff") - self.prediction = "" + changelog_comment = "**Changelog update could not be generated.**" + changelog_comment += ( + "\n\n> ⚠️ These changes were not pushed because the existing " + "CHANGELOG.md could not be read safely." + ) + try: + self.git_provider.publish_comment(changelog_comment) + except Exception as fallback_error: + get_logger().exception( + f"Failed to publish changelog fallback after a read error: {fallback_error}" + ) - async def _get_prediction(self, model: str): + def _publish_changelog_write_error_fallback(self, answer: str): + changelog_comment = f"**Changelog updates:** 🔄\n\n{answer}" + changelog_comment += ( + "\n\n> ⚠️ The repository update could not be confirmed. " + "The generated changelog is preserved here for recovery." + ) + try: + self.git_provider.publish_comment(changelog_comment) + except Exception as fallback_error: + get_logger().exception( + f"Failed to publish changelog fallback after a write error: {fallback_error}" + ) + + async def _prepare_prediction(self, model: str): variables = copy.deepcopy(self.vars) - variables["diff"] = self.patches_diff # update diff if get_settings().pr_update_changelog.add_pr_link: variables["pr_link"] = self.git_provider.get_pr_url() - environment = Environment(undefined=StrictUndefined) - system_prompt = environment.from_string(get_settings().pr_update_changelog_prompt.system).render(variables) - user_prompt = environment.from_string(get_settings().pr_update_changelog_prompt.user).render(variables) + output_token_reserve = getattr(self.ai_handler, "get_output_token_reserve", None) + budget = AttemptTokenBudget.for_prompt_attempt( + model, + getattr(self.git_provider, "pr", None), + variables, + get_settings().pr_update_changelog_prompt.system, + get_settings().pr_update_changelog_prompt.user, + ai_handler=self.ai_handler, + output_token_reserve=output_token_reserve, + ) + budget.require_input_capacity( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + preserve_minimum=True, + ) + patches_diff = get_pr_diff( + self.git_provider, + budget.token_handler, + model, + output_token_reserve=output_token_reserve, + ) + if not patches_diff: + raise ValueError(f"No PR diff fits the /update_changelog request for {model}") + + fitted = budget.fit_prompt_variable( + variables, + "diff", + patches_diff, + ai_handler=self.ai_handler, + default_output_tokens=OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD, + preserve_minimum=True, + ) + if fitted.optional_text != patches_diff: + raise ValueError( + f"The complete packed changelog diff does not fit the token limit for {model}" + ) + self.patches_diff = fitted.optional_text + self._attempt_system_prompt = fitted.system_prompt + self._attempt_user_prompt = fitted.user_prompt + get_logger().debug("PR diff", artifact=self.patches_diff) + self.prediction = await self._get_prediction(model) + + async def _get_prediction(self, model: str): + system_prompt = self._attempt_system_prompt + user_prompt = self._attempt_user_prompt response, finish_reason = await self.ai_handler.chat_completion( model=model, system=system_prompt, user=user_prompt, temperature=get_settings().config.temperature) @@ -166,29 +273,51 @@ def _prepare_changelog_update(self) -> Tuple[str, str]: return new_file_content, answer - def _push_changelog_update(self, new_file_content, answer): + async def _push_changelog_update(self, new_file_content, answer): + if not self.git_provider.is_supported("push_code"): + # Its only caller already gates on self.commit_changelog, which is False + # whenever this capability is missing; kept local so the guard holds even + # if a future caller reaches this method some other way. + return if get_settings().pr_update_changelog.get("skip_ci_on_push", True): commit_message = "[skip ci] Update CHANGELOG.md" else: commit_message = "Update CHANGELOG.md" - self.git_provider.create_or_update_pr_file( - file_path="CHANGELOG.md", - branch=self.git_provider.get_pr_branch(), - contents=new_file_content, - message=commit_message, - ) + try: + written_commit = self.git_provider.create_or_update_pr_file( + file_path="CHANGELOG.md", + branch=self.git_provider.get_pr_branch(), + contents=new_file_content, + message=commit_message, + ) + except Exception: + self._publish_changelog_write_error_fallback(answer) + raise - sleep(5) # wait for the file to be updated try: - if get_settings().config.git_provider == "github": - last_commit_id = list(self.git_provider.pr.get_commits())[-1] + await asyncio.sleep(5) # wait for the file to be updated + except asyncio.CancelledError: + # Preserve the user-visible fallback after a successful write while keeping + # cancellation authoritative. + try: + if self.git_provider.supports_changelog_update_review(): + self.git_provider.publish_comment(f"**Changelog updates: 🔄**\n\n{answer}") + except Exception as feedback_error: + get_logger().exception( + f"Failed to publish changelog fallback during cancellation: {feedback_error}" + ) + raise + try: + if self.git_provider.supports_changelog_update_review(): + if written_commit is None: + raise ValueError("The changelog write did not return a commit for review") d = dict( body="CHANGELOG.md update", path="CHANGELOG.md", line=max(2, len(answer.splitlines())), start_line=1, ) - self.git_provider.pr.create_review(commit=last_commit_id, comments=[d]) + self.git_provider.pr.create_review(commit=written_commit, event="COMMENT", comments=[d]) except Exception: # we can't create a review for some reason, let's just publish a comment self.git_provider.publish_comment(f"**Changelog updates: 🔄**\n\n{answer}") @@ -209,10 +338,16 @@ def _get_default_changelog(self): return example_changelog def _get_changelog_file(self): + strict_read = self.commit_changelog and get_settings().config.publish_output try: - self.changelog_file = self.git_provider.get_pr_file_content( - "CHANGELOG.md", self.git_provider.get_pr_branch() - ) + if strict_read: + self.changelog_file = self.git_provider.get_pr_file_content( + "CHANGELOG.md", self.git_provider.get_pr_branch(), propagate_errors=True + ) + else: + self.changelog_file = self.git_provider.get_pr_file_content( + "CHANGELOG.md", self.git_provider.get_pr_branch() + ) if isinstance(self.changelog_file, bytes): self.changelog_file = self.changelog_file.decode('utf-8') @@ -222,6 +357,8 @@ def _get_changelog_file(self): self.changelog_file_str = "\n".join(changelog_file_lines) except Exception as e: get_logger().warning(f"Error getting changelog file: {e}") + if strict_read: + self.changelog_read_error = e self.changelog_file_str = "" self.changelog_file = "" return diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 797439aeec..6907573742 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -1,3 +1,4 @@ +import asyncio import copy import math import re @@ -5,17 +6,20 @@ from urllib.parse import urlparse import aiohttp +from atlassian import Jira from pr_agent.algo.pr_processing import OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD +from pr_agent.algo.token_budget import AttemptTokenBudget from pr_agent.algo.token_handler import TokenHandler -from pr_agent.algo.utils import get_max_tokens from pr_agent.config_loader import get_settings -from pr_agent.git_providers import AzureDevopsProvider, GithubProvider, GitLabProvider +from pr_agent.git_providers.git_provider import GitProvider from pr_agent.log import get_logger # Compile the regex pattern once, outside the function GITHUB_TICKET_PATTERN = re.compile( - r'(https://github[^/]+/[^/]+/[^/]+/issues/\d+)|(\b(\w+)/(\w+)#(\d+)\b)|(#\d+)' + r'(https://github[^/]+/[^/]+/[^/]+/issues/\d+)' + r'|((?" in https://.atlassian.net). Only Jira Cloud is +# supported: the base URL is built from this name rather than taken as a free-form URL, so +# repo-controlled config cannot redirect the authenticated request (and the token) to an +# arbitrary host. The name must be a single DNS label (letters, digits, hyphens) so it +# cannot contain '.', '/', ':', '@' etc. that would let it escape *.atlassian.net. +JIRA_SITE_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", re.IGNORECASE) + +# A Jira project key as configured in jira.project_keys: the "" in PROJECT-123. +# Bounded like the prefix in find_jira_tickets (2-10 letters) so an entry can only ever be +# a plain label, never a URL or a full ticket key. Case-sensitive: Jira project keys are +# upper case, so a configured "proj" is a typo rather than a spelling variant, and +# accepting it silently would widen the lookup to a project the repo never named. Keys +# found in the PR text are still matched case-insensitively and upper-cased +# (find_jira_tickets) before they are compared. +JIRA_PROJECT_KEY_PATTERN = re.compile(r"^[A-Z]{2,10}$") + + +def _jira_cloud_base_url(): + """ + Return the Jira Cloud base URL built from the configured site name, or None if the + site is missing or invalid. Building the URL from a validated site name (rather than + accepting a free-form base URL from settings) ensures the authenticated request can + only ever go to https://.atlassian.net, even if settings were overridden by + untrusted repo configuration. + """ + site = get_settings().get("JIRA.JIRA_SITE", None) + if not site: + return None + site = str(site).strip() + if not JIRA_SITE_PATTERN.match(site): + get_logger().warning( + f"Invalid jira_site '{site}'; expected a Jira Cloud site name like 'mycompany' " + f"(the '' in https://.atlassian.net). Skipping Jira ticket lookup.") + return None + return f"https://{site}.atlassian.net" + + +def _jira_project_keys(): + """ + Return the configured jira.project_keys allowlist as a set of project keys, or None + when nothing was supplied (look up every key found). + + Only three values count as "nothing was supplied": the option missing entirely, the + shipped empty list, and a top-level string that is blank, the last because + `jira__project_keys=""` is how a shell spells an unset environment variable. + + Everything else is a supplied value and is judged on its entries. Entries that are not + plain upper-case project keys, including a blank one (so `[""]` is a malformed + override rather than a second spelling of the default), are ignored with a warning, + and a supplied value left with no valid entry yields an empty set, which drops every + key. So does a value that is not a list at all (a boolean or number from a YAML or CLI + override). A typo fails closed instead of silently widening the lookup to every + key-shaped string. Accepts a list or a comma-separated string, the latter for + environment-variable overrides (jira__project_keys="PROJ,OPS"). + """ + configured = get_settings().get("JIRA.PROJECT_KEYS", None) + if configured is None: + return None + if isinstance(configured, str): + if not configured.strip(): + return None + configured = configured.split(",") + elif not isinstance(configured, (list, tuple)): + # A YAML/CLI override such as `project_keys=true` or `=false` is neither + # "unset" nor a list; fail closed rather than guess what it meant. + get_logger().warning( + f"jira.project_keys must be a list of project keys, got {type(configured).__name__}; " + "skipping Jira ticket lookup until it is fixed") + return set() + if not configured: + # The shipped default. An empty container carries no entry to be wrong about. + return None + allowed = set() + for index, item in enumerate(configured): + # Only strings are candidates: a TOML/YAML boolean or null in the list must not + # be stringified into a key-shaped label ("TRUE", "NONE") that then filters. + key = item.strip() if isinstance(item, str) else item + if not isinstance(key, str) or not JIRA_PROJECT_KEY_PATTERN.match(key): + # The entry is named by position and type, never quoted back. Its content is + # configuration this tool does not control: it can be long, hold a secret + # pasted into the wrong setting, or carry newlines that would forge log + # lines. The position is what an operator needs to find and fix it. + get_logger().warning( + f"Ignoring invalid jira.project_keys entry at index {index} " + f"(type {type(item).__name__}); expected a plain upper-case project key " + "like 'PROJ'") + continue + allowed.add(key) + if not allowed: + get_logger().warning( + "jira.project_keys has no valid entry; skipping Jira ticket lookup until it is fixed") + return allowed + def find_jira_tickets(text): - # Regular expression patterns for JIRA tickets + # Regular expression patterns for JIRA tickets. Matching is case-insensitive so + # lowercased branch names (e.g. bugfix/abc-123-description) are detected; keys are + # normalized to upper case to match Jira's canonical form. patterns = [ r'\b[A-Z]{2,10}-\d{1,7}\b', # Standard JIRA ticket format (e.g., PROJ-123) r'(?:https?://[^\s/]+/browse/)?([A-Z]{2,10}-\d{1,7})\b' # JIRA URL or just the ticket ] - tickets = set() + # Preserve first-seen order while de-duplicating, so the MAX_TICKETS cap applied + # later is deterministic across runs (a plain set would fetch arbitrary tickets). + seen = set() + tickets = [] for pattern in patterns: - matches = re.findall(pattern, text) + matches = re.findall(pattern, text, flags=re.IGNORECASE) for match in matches: if isinstance(match, tuple): # If it's a tuple (from the URL pattern), take the last non-empty group @@ -42,9 +161,187 @@ def find_jira_tickets(text): else: ticket = match if ticket: - tickets.add(ticket) + ticket = ticket.upper() + if ticket not in seen: + seen.add(ticket) + tickets.append(ticket) + + return tickets + + +def _get_jira_client(): + """ + Build a Jira Cloud client from the [jira] settings. Returns None if Jira is not + configured. Only Jira Cloud is supported: the base URL is derived from a validated + site name (jira_site) so untrusted repo configuration cannot redirect the + authenticated request to an arbitrary host. Cloud authenticates with the account + email + API token. The REST API version is pinned via JIRA_API_VERSION (see its + definition for why). + """ + site = get_settings().get("JIRA.JIRA_SITE", None) + api_email = get_settings().get("JIRA.JIRA_API_EMAIL", None) + api_token = get_settings().get("JIRA.JIRA_API_TOKEN", None) + base_url = _jira_cloud_base_url() # None if site is missing or invalid (already warned if invalid) + if not (base_url and api_email and api_token): + # Warn when Jira is partially configured: some [jira] value is set but the required + # site + email + token are incomplete, which is likely a mistake. Stay silent when + # nothing is set (Jira simply not in use), and don't double-warn when the site was + # set but invalid (_jira_cloud_base_url already warned about that). + site_set_but_invalid = site and not base_url + if any([site, api_email, api_token]) and not site_set_but_invalid: + missing = [ + name for name, value in ( + ("jira_site", site), + ("jira_api_email", api_email), + ("jira_api_token", api_token), + ) if not value + ] + get_logger().warning( + f"Jira is partially configured; skipping Jira ticket lookup. Missing: {', '.join(missing)}") + return None + try: + return Jira(url=base_url, username=api_email, password=api_token, api_version=JIRA_API_VERSION) + except Exception as e: + get_logger().error(f"Failed to initialize Jira client: {e}", + artifact={"traceback": traceback.format_exc()}) + return None + + +def extract_jira_tickets(text, max_characters=MAX_TICKET_CHARACTERS, max_tickets=MAX_TICKETS): + """ + Find Jira ticket keys in the given text and fetch their content. Returns a list of + ticket dicts in the same shape used by the rest of the ticket-analysis flow. Returns + an empty list when no keys are found or when Jira is not configured. + + Candidates are tried in first-seen order until max_tickets resolve or + MAX_JIRA_FETCH_ATTEMPTS lookups have been made. Counting resolved tickets rather than + truncating the candidate list keeps key-shaped noise ("SHA-256", "UTF-8") from + displacing a real ticket that appears later in the text. + + When jira.project_keys is configured, candidates whose prefix isn't in that allowlist + are dropped before any lookup, so they don't consume a fetch attempt. + """ + # Look for keys before building a client: most PRs have none, and building the + # client first would do needless work (and log a noisy init failure if Jira is + # misconfigured) even when there is nothing to fetch. + keys = find_jira_tickets(text or "") + if not keys: + return [] + + # Optional allowlist: key-shaped false positives like "SHA-256" or "UTF-8" match the + # regex but aren't real Jira keys. Filtering by known project prefixes here means they + # never consume one of the MAX_JIRA_FETCH_ATTEMPTS lookups below. Empty keeps today's + # behaviour; a supplied but invalid allowlist drops every key (see _jira_project_keys). + allowed_projects = _jira_project_keys() + if allowed_projects is not None: + # Partitioned in one pass: the candidate list is not capped until lookups begin, + # so a PR carrying many distinct key-shaped tokens would otherwise pay a + # membership scan of the skipped list for every one of them. + kept, skipped = [], [] + for key in keys: + target = kept if key.split("-", 1)[0] in allowed_projects else skipped + target.append(key) + if skipped: + get_logger().debug( + f"Skipping Jira lookup for keys outside jira.project_keys: {', '.join(skipped)}") + keys = kept + if not keys: + return [] + + jira_client = _get_jira_client() + if jira_client is None: + return [] + + base_url = _jira_cloud_base_url() or "" + # Custom field that holds acceptance criteria / requirements. The field id is + # instance-specific (e.g. "customfield_10127"), so it must be configured; empty + # means no requirements are extracted. + requirements_field = get_settings().get("JIRA.JIRA_REQUIREMENTS_FIELD", "") or "" + tickets_content = [] + for attempt, key in enumerate(keys, start=1): + if len(tickets_content) >= max_tickets: + get_logger().info(f"Reached the cap of {max_tickets} Jira tickets; skipping remaining keys") + break + if attempt > MAX_JIRA_FETCH_ATTEMPTS: + get_logger().info( + f"Stopped after {MAX_JIRA_FETCH_ATTEMPTS} Jira lookups; skipping remaining keys") + break + try: + issue = jira_client.issue(key) + except Exception as e: + get_logger().warning(f"Failed to fetch Jira ticket {key}: {e}") + continue + if not issue: + continue - return list(tickets) + fields = issue.get("fields", {}) or {} + # The client is pinned to REST v2 (see _get_jira_client), which returns + # description and rich-text custom fields as plain wiki-markup strings. The + # isinstance guards below defend against anything non-string (e.g. v3 ADF dicts). + body = fields.get("description") or "" + if not isinstance(body, str): + body = "" + if len(body) > max_characters: + body = body[:max_characters] + "..." + + requirements = "" + if requirements_field: + requirements = fields.get(requirements_field) or "" + if not isinstance(requirements, str): + requirements = "" + if len(requirements) > max_characters: + requirements = requirements[:max_characters] + "..." + + labels = fields.get("labels", []) or [] + tickets_content.append({ + "ticket_id": key, + "ticket_url": f"{base_url}/browse/{key}" if base_url else "", + "title": fields.get("summary", ""), + "body": body, + "requirements": requirements, + "labels": ", ".join(labels), + }) + return tickets_content + + +def _get_pr_title(git_provider) -> str: + """Return the PR/MR title across providers (GitHub/Bitbucket use .pr, GitLab .mr).""" + for attr in ("pr", "mr"): + obj = getattr(git_provider, attr, None) + title = getattr(obj, "title", None) + if title: + return title + return "" + + +def add_jira_tickets(git_provider, tickets_content): + """ + Provider-agnostic Jira lookup. Scans the PR title, description and branch name for + Jira ticket keys and appends any found tickets to tickets_content (de-duplicated by + ticket_url). No-op when Jira is not configured. Works for any git provider, since it + only relies on get_user_description() and get_pr_branch(). + + MAX_TICKETS is the overall per-PR cap, so any provider-native tickets already in + tickets_content count against it: only the remaining budget is offered to the Jira + lookup, keeping the existing tickets first. + """ + try: + if len(tickets_content) >= MAX_TICKETS: + return tickets_content + jira_context = "\n".join(filter(None, [ + _get_pr_title(git_provider), + git_provider.get_user_description() or "", + git_provider.get_pr_branch() or "", + ])) + existing_urls = {t.get("ticket_url") for t in tickets_content} + remaining = MAX_TICKETS - len(tickets_content) + for jira_ticket in extract_jira_tickets(jira_context, MAX_TICKET_CHARACTERS, remaining): + if jira_ticket.get("ticket_url") not in existing_urls: + tickets_content.append(jira_ticket) + except Exception as e: + get_logger().error(f"Error extracting Jira tickets: {e}", + artifact={"traceback": traceback.format_exc()}) + return tickets_content _ASANA_TASK_URL_PATTERN = re.compile( @@ -60,6 +357,7 @@ def find_jira_tickets(text): MAX_ASANA_REQUEST_TIMEOUT = 60 MAX_ASANA_TICKETS = 3 MAX_GITHUB_TICKETS = 3 +MAX_GITHUB_TICKET_LOOKUPS = 30 MAX_GITLAB_TICKETS = 3 GITLAB_TICKET_PATTERN = re.compile( r"(?Phttps?://[^\s<>(),;]+)" @@ -75,65 +373,102 @@ def fit_related_tickets_to_prompt_budget( system_prompt: str, user_prompt: str, model: str, + *, + ai_handler=None, + output_token_reserve=None, ) -> tuple[dict, TokenHandler]: """Fit complete related-ticket records while preserving room for the PR diff.""" prompt_vars = copy.deepcopy(raw_vars) related_tickets = prompt_vars.get("related_tickets") - if not isinstance(related_tickets, list) or not related_tickets: - return prompt_vars, TokenHandler( - pr, - prompt_vars, - system_prompt, - user_prompt, - model=model, - ) + if not isinstance(related_tickets, list): + related_tickets = [] raw_tickets = copy.deepcopy(related_tickets) prompt_vars["related_tickets"] = [] - token_handler = TokenHandler( + budget = AttemptTokenBudget.for_prompt_attempt( + model, pr, prompt_vars, system_prompt, user_prompt, - model=model, + ai_handler=ai_handler, + output_token_reserve=output_token_reserve, + ) + if not raw_tickets: + budget.require_input_capacity( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + preserve_minimum=True, + ) + return prompt_vars, budget.token_handler + + prompt_token_limit = budget.input_token_limit( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + preserve_minimum=True, + additional_input_reserve=OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, ) - prompt_token_limit = max( - token_handler.prompt_tokens, - get_max_tokens(model) - 2 * OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + full_vars = copy.deepcopy(prompt_vars) + full_vars["related_tickets"] = copy.deepcopy(raw_tickets) + full_budget = AttemptTokenBudget.for_prompt_attempt( + model, + pr, + full_vars, + system_prompt, + user_prompt, + ai_handler=ai_handler, + output_token_reserve=output_token_reserve, ) + if full_budget.prompt_tokens <= prompt_token_limit: + full_budget.require_input_capacity( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + preserve_minimum=True, + ) + return full_vars, full_budget.token_handler - lower_bound = 1 - upper_bound = len(raw_tickets) + best_vars = None + best_budget = None + lower_bound = 0 + upper_bound = len(raw_tickets) - 1 while lower_bound <= upper_bound: prefix_size = (lower_bound + upper_bound) // 2 candidate_vars = copy.deepcopy(prompt_vars) candidate_vars["related_tickets"] = copy.deepcopy(raw_tickets[:prefix_size]) - candidate_handler = TokenHandler( + candidate_vars["related_tickets_omitted"] = len(raw_tickets) - prefix_size + candidate_budget = AttemptTokenBudget.for_prompt_attempt( + model, pr, candidate_vars, system_prompt, user_prompt, - model=model, + ai_handler=ai_handler, + output_token_reserve=output_token_reserve, ) - if candidate_handler.prompt_tokens > prompt_token_limit: + if candidate_budget.prompt_tokens > prompt_token_limit: upper_bound = prefix_size - 1 else: - prompt_vars = candidate_vars - token_handler = candidate_handler + best_vars = candidate_vars + best_budget = candidate_budget lower_bound = prefix_size + 1 + if best_vars is None or best_budget is None: + raise ValueError("Related-ticket omission marker exceeds the prompt token budget") + + prompt_vars = best_vars included_tickets = len(prompt_vars["related_tickets"]) - if included_tickets < len(raw_tickets): - get_logger().info( - "Clipped related tickets to preserve the prompt token budget", - artifact={ - "included_tickets": included_tickets, - "omitted_tickets": len(raw_tickets) - included_tickets, - "model": model, - }, - ) + get_logger().info( + "Clipped related tickets to preserve the prompt token budget", + artifact={ + "included_tickets": included_tickets, + "omitted_tickets": len(raw_tickets) - included_tickets, + "model": model, + }, + ) + + best_budget.require_input_capacity( + OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD, + preserve_minimum=True, + ) - return prompt_vars, token_handler + return prompt_vars, best_budget.token_handler def find_asana_tickets(text: str | None) -> list: @@ -224,26 +559,39 @@ async def _fetch_asana_ticket_contents( timeout = aiohttp.ClientTimeout(total=_get_asana_request_timeout()) headers = {"Authorization": f"Bearer {api_token.strip()}"} - tickets_content = [] async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session: # Bound attempts as well as successful results so invalid references cannot # multiply request latency, rate-limit usage, or warning logs. + selected_tickets = [] for ticket_url in ticket_urls[:max_tickets]: - if len(tickets_content) >= max_tickets: - break - task_gid = None try: task_gid = _get_asana_task_gid(ticket_url) - ticket_content = await _fetch_asana_ticket_content( - session, - ticket_url, - max_body_characters, - ) except Exception as e: - task_label = task_gid or "invalid reference" - get_logger().warning(f"Failed to fetch Asana task {task_label}: {e}") + get_logger().warning(f"Failed to fetch Asana task invalid reference: {e}") continue - tickets_content.append(ticket_content) + selected_tickets.append((ticket_url, task_gid)) + + async def fetch_ticket(ticket_url): + try: + return await _fetch_asana_ticket_content(session, ticket_url, max_body_characters), None + except Exception as e: + return None, e + + tasks = [asyncio.create_task(fetch_ticket(ticket_url)) for ticket_url, _task_gid in selected_tickets] + try: + results = await asyncio.gather(*tasks) + except BaseException: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise + + tickets_content = [] + for (_ticket_url, task_gid), (ticket_content, error) in zip(selected_tickets, results, strict=True): + if error is not None: + get_logger().warning(f"Failed to fetch Asana task {task_gid}: {error}") + continue + tickets_content.append(ticket_content) return tickets_content @@ -314,7 +662,8 @@ def extract_gitlab_ticket_references(pr_description, repo_path, gitlab_url): return references[:MAX_GITLAB_TICKETS] -def extract_ticket_links_from_pr_description(pr_description, repo_path, base_url_html='https://github.com'): +def extract_ticket_links_from_pr_description(pr_description, repo_path, base_url_html='https://github.com', + max_tickets=MAX_GITHUB_TICKETS): """ Extract all ticket links from PR description """ @@ -329,24 +678,62 @@ def _add(url): github_tickets.append(url) try: - # Use the updated pattern to find matches - matches = GITHUB_TICKET_PATTERN.findall(pr_description) - - for match in matches: - if match[0]: # Full URL match - _add(match[0]) - elif match[1]: # Shorthand notation match: owner/repo#issue_number - owner, repo, issue_number = match[2], match[3], match[4] - _add(f"{base_url_html.strip('/')}/{owner}/{repo}/issues/{issue_number}") - else: # #123 format - issue_number = match[5][1:] # remove # + custom_pattern = None + custom_matches = [] + custom_regex = get_settings().get("config.description_issue_regex", "") + if custom_regex: + try: + custom_pattern = re.compile(custom_regex) + if custom_pattern.groups != 1: + raise ValueError("expected exactly one capturing group for the issue number") + custom_matches = list(custom_pattern.finditer(pr_description)) + except (re.error, TypeError, ValueError, OverflowError, RecursionError) as e: + get_logger().warning(f"Invalid description_issue_regex: {e}; using default pattern.") + custom_pattern = None + + candidates = [] + explicit_spans = [] + for match in GITHUB_TICKET_PATTERN.finditer(pr_description): + if match[1]: # Full URL match + candidates.append((match.start(), match[1])) + explicit_spans.append(match.span()) + elif match[2]: # Shorthand notation match: owner/repo#issue_number + owner, repo, issue_number = match[3], match[4], match[5] + candidates.append((match.start(), + f"{base_url_html.strip('/')}/{owner}/{repo}/issues/{issue_number}")) + explicit_spans.append(match.span()) + elif custom_pattern is None: # Default #123 format + issue_number = match[6][1:] if (issue_number.isdigit() and repo_path and len(issue_number) <= MAX_SHORTHAND_ISSUE_DIGITS): - _add(f"{base_url_html.strip('/')}/{repo_path}/issues/{issue_number}") + candidates.append((match.start(), + f"{base_url_html.strip('/')}/{repo_path}/issues/{issue_number}")) + + if custom_pattern is not None and repo_path: + explicit_index = 0 + for match in custom_matches: + issue_number = match[1] + # Keep explicit references from also becoming tickets in the current repository. + # Use one forward pointer because both match sequences are ordered. + start, end = match.span(1) + while explicit_index < len(explicit_spans) and explicit_spans[explicit_index][1] <= start: + explicit_index += 1 + if explicit_index < len(explicit_spans) and explicit_spans[explicit_index][0] < end: + continue + if issue_number and issue_number.isascii() and issue_number.isdigit(): + try: + int(issue_number) + except ValueError: + continue + candidates.append((match.start(), + f"{base_url_html.strip('/')}/{repo_path}/issues/{issue_number}")) + + for _, url in sorted(candidates, key=lambda candidate: candidate[0]): + _add(url) - if len(github_tickets) > MAX_GITHUB_TICKETS: + if len(github_tickets) > max_tickets: get_logger().info(f"Too many tickets found in PR description: {len(github_tickets)}") - github_tickets = github_tickets[:MAX_GITHUB_TICKETS] + github_tickets = github_tickets[:max_tickets] except Exception as e: get_logger().error(f"Error extracting tickets error= {e}", artifact={"traceback": traceback.format_exc()}) @@ -460,8 +847,23 @@ def _get_repo_obj_for_ticket(git_provider, ticket_url, repo_name, repo_obj_cache return repo_obj +def _provider_supports(git_provider, capability: str) -> bool: + """Read an opt-in ticket capability from a provider. + + Objects outside the GitProvider hierarchy (test doubles, minimal adapters) may not + define the method at all; absence means the capability is not supported, which keeps + the previous behaviour for providers that matched none of the concrete classes. + + A permissive double such as a bare MagicMock answers every valid capability truthily and + so takes the first branch. Unknown capabilities raise to expose misspelled names. + """ + if not hasattr(GitProvider, capability): + raise AttributeError(f"unknown provider capability: {capability!r}") + check = getattr(git_provider, capability, None) + return bool(check()) if callable(check) else False + + async def extract_tickets(git_provider): - MAX_TICKET_CHARACTERS = 10000 try: user_description = _get_user_description_for_asana(git_provider) asana_ticket_urls = find_asana_tickets(user_description) @@ -475,9 +877,10 @@ async def extract_tickets(git_provider): get_logger().warning(f"Failed to initialize Asana task fetching: {e}") asana_tickets_content = [] - if isinstance(git_provider, GithubProvider): + if _provider_supports(git_provider, "supports_issue_url_tickets"): description_tickets = extract_ticket_links_from_pr_description( - user_description, git_provider.repo, git_provider.base_url_html + user_description, git_provider.repo, git_provider.base_url_html, + max_tickets=MAX_GITHUB_TICKET_LOOKUPS, ) branch_name = git_provider.get_pr_branch() branch_tickets = extract_ticket_links_from_branch_name( @@ -492,25 +895,29 @@ async def extract_tickets(git_provider): if len(merged) > MAX_GITHUB_TICKETS: get_logger().info(f"Too many GitHub tickets (description + branch): {len(merged)}") - # Preserve GitHub's established three-candidate budget. Asana tasks use - # their own bounded budget and therefore do not displace GitHub issues. - tickets = merged[:MAX_GITHUB_TICKETS] + # Bound lookups separately so skipped PRs do not consume the issue budget. + tickets = merged[:MAX_GITHUB_TICKET_LOOKUPS] tickets_content = [] repo_obj_cache = {} if tickets: for ticket in tickets: - repo_name, original_issue_number = git_provider._parse_issue_url(ticket) + if len(tickets_content) >= MAX_GITHUB_TICKETS: + break try: + repo_name, original_issue_number = git_provider._parse_issue_url(ticket) repo_obj = _get_repo_obj_for_ticket(git_provider, ticket, repo_name, repo_obj_cache) issue_main = repo_obj.get_issue(original_issue_number) except Exception as e: - get_logger().error(f"Error getting main issue {repo_name}#{original_issue_number}: {e}", + get_logger().error(f"Error getting main issue {ticket!r}: {e}", artifact={"traceback": traceback.format_exc()}) continue + if getattr(issue_main, "pull_request", None) is not None: + continue + issue_body_str = issue_main.body or "" if len(issue_body_str) > MAX_TICKET_CHARACTERS: issue_body_str = issue_body_str[:MAX_TICKET_CHARACTERS] + "..." @@ -549,7 +956,7 @@ async def extract_tickets(git_provider): get_logger().warning(f"Failed to fetch sub-issue content for {sub_issue_url}: {e}") except Exception as e: - get_logger().warning(f"Failed to fetch sub-issues for {ticket}: {e}") + get_logger().warning(f"Failed to fetch sub-issues for {ticket!r}: {e}") # Extract labels labels = [] @@ -570,9 +977,11 @@ async def extract_tickets(git_provider): }) tickets_content.extend(asana_tickets_content) + # Provider-agnostic Jira lookup (see add_jira_tickets); no-op when Jira is unconfigured. + add_jira_tickets(git_provider, tickets_content) return tickets_content - elif isinstance(git_provider, GitLabProvider): + elif _provider_supports(git_provider, "supports_issue_reference_tickets"): references = extract_gitlab_ticket_references( user_description, git_provider.id_project, @@ -581,7 +990,8 @@ async def extract_tickets(git_provider): tickets_content = [] for project_path, issue_iid in references: try: - project = git_provider.gl.projects.get(project_path) + # Only the issue manager is needed; avoid fetching unused project metadata. + project = git_provider.gl.projects.get(project_path, lazy=True) issue = project.issues.get(issue_iid) except Exception as e: get_logger().error( @@ -605,9 +1015,11 @@ async def extract_tickets(git_provider): ) tickets_content.extend(asana_tickets_content) + # Provider-agnostic Jira lookup (see add_jira_tickets); no-op when Jira is unconfigured. + add_jira_tickets(git_provider, tickets_content) return tickets_content - elif isinstance(git_provider, AzureDevopsProvider): + elif _provider_supports(git_provider, "supports_linked_work_item_tickets"): tickets_info = git_provider.get_linked_work_items() tickets_content = [] for ticket in tickets_info: @@ -616,13 +1028,21 @@ async def extract_tickets(git_provider): if len(ticket_body_str) > MAX_TICKET_CHARACTERS: ticket_body_str = ticket_body_str[:MAX_TICKET_CHARACTERS] + "..." + # Cap acceptance criteria like the body, so a large work-item field + # can't push an unbounded blob into the review prompt. + requirements_str = ticket.get("acceptance_criteria", "") or "" + if not isinstance(requirements_str, str): + requirements_str = "" + if len(requirements_str) > MAX_TICKET_CHARACTERS: + requirements_str = requirements_str[:MAX_TICKET_CHARACTERS] + "..." + tickets_content.append( { "ticket_id": ticket.get("id"), "ticket_url": ticket.get("url"), "title": ticket.get("title"), "body": ticket_body_str, - "requirements": ticket.get("acceptance_criteria", ""), + "requirements": requirements_str, "labels": ", ".join(ticket.get("labels", [])), } ) @@ -634,10 +1054,18 @@ async def extract_tickets(git_provider): # Preserve the existing Azure work-item result set. The independently bounded # Asana results add context without imposing a new cap on Azure's established behaviour. tickets_content.extend(asana_tickets_content) + # Provider-agnostic Jira lookup (see add_jira_tickets); no-op when Jira is unconfigured. + add_jira_tickets(git_provider, tickets_content) return tickets_content - if asana_ticket_urls: - return asana_tickets_content + # Providers with no ticket integration of their own still reach Jira: keys are + # usually referenced in the PR title, description or branch name rather than by a + # provider-native link. The Asana results seed the list so they count against + # MAX_TICKETS, as on the provider-specific paths. Returning None when nothing at + # all was found keeps the "provider unsupported" contract intact. + tickets_content = add_jira_tickets(git_provider, list(asana_tickets_content)) + if asana_ticket_urls or tickets_content: + return tickets_content except Exception as e: get_logger().error(f"Error extracting tickets error= {e}", @@ -673,7 +1101,3 @@ async def extract_and_cache_pr_tickets(git_provider, vars): else: get_logger().info("Using cached tickets", artifact={"tickets": related_tickets}) vars['related_tickets'] = related_tickets - - -def check_tickets_relevancy(): - return True diff --git a/pyproject.toml b/pyproject.toml index 6fba1427be..ec64d90c1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pr-agent" -version = "0.45.0" +version = "0.46.0" authors = [ { name = "QodoAI", email = "ofir.f@qodo.ai" }, @@ -36,23 +36,25 @@ dependencies = [ "azure-devops==7.1.0b4", "azure-identity==1.25.0", "boto3==1.43.78", - "certifi==2024.8.30", - "dynaconf>=3.2.13,<4", + "certifi>=2026.7.22", + "dynaconf>=3.3.5,<4", "fastapi>=0.141.1,<1", - "GitPython>=3.1.59,<4", + "GitPython>=3.1.62,<4", "starlette>=1.6.0,<2", "google-cloud-aiplatform==1.154.0", # google-cloud-aiplatform==1.154.0 requires storage <4,>=1.32.0 on py<3.13 and <4,>=3.10.0 on py>=3.13. - # Pin per-Python (exact, like the rest of this list) so existing installs are unchanged and py3.13 resolves. See #2480. + # Pin per-Python so existing installs are unchanged and py3.13 resolves. See #2480. "google-cloud-storage==2.10.0; python_version < '3.13'", "google-cloud-storage==3.12.0; python_version >= '3.13'", - "protobuf==6.33.6", # grpcio-status 1.80.0 forces >=6.31.1,<7; pinned to lock the resolved transitive version + # Keep protobuf explicit to lock the transitive version resolved with the gRPC dependency stack. + # Revalidate compatibility when updating grpcio-status or related Google/gRPC dependencies. + "protobuf==6.33.6", "Jinja2==3.1.6", - "litellm==1.100.0", - "loguru==0.7.2", + "litellm==1.101.0", + "loguru==0.7.3", "msrest==0.7.1", "openai>=1.55.3", - "PyGithub==1.59.*", + "PyGithub>=2.10,<3", "PyJWT>=2.13.0,<3", "PyYAML==6.0.1", "python-gitlab==8.3.0", @@ -67,7 +69,7 @@ dependencies = [ "langfuse==3.14.5", "gunicorn==23.0.0", "pydantic==2.13.3", - # litellm 1.100.0 declares pydantic-settings as a base dependency and imports it unconditionally from + # litellm 1.101.0 declares pydantic-settings as a base dependency and imports it unconditionally from # litellm/integrations/otel/model/config.py. Keep the exact pin to lock the resolved transitive version. # Version matches litellm's own constraint (>=2.14.1,<3.0); the existing observability guard covers the callback import. "pydantic-settings==2.14.2", @@ -78,6 +80,9 @@ dependencies = [ "opentelemetry-api==1.43.0", "opentelemetry-sdk==1.43.0", "opentelemetry-exporter-otlp-proto-http==1.43.0", + # Prometheus scrape endpoint for [otel] command telemetry (native /metrics on + # the webhook apps). Multiprocess-capable; see pr_agent/telemetry/prometheus.py. + "prometheus-client==0.21.1", ] [project.urls] @@ -89,6 +94,9 @@ dependencies = [ [tool.setuptools] include-package-data = true +[tool.setuptools.package-data] +pr_agent = ["py.typed"] + [tool.setuptools.packages.find] where = ["."] include = [ @@ -97,7 +105,11 @@ include = [ [project.optional-dependencies] # AWS Lambda deployment (docker/Dockerfile.lambda). -lambda = ["mangum==0.17.0"] +# Keep mangum >=0.21.0 for Python 3.14: older releases call +# asyncio.get_event_loop() with no fallback, which raises RuntimeError +# when no loop is set. Skip 0.20.0, whose 3.14 support was reverted +# upstream; 0.22.0 is end-to-end tested against a real Lambda runtime. +lambda = ["mangum==0.22.0"] # OTLP/gRPC transport for pr-agent telemetry (OTEL.OTLP_PROTOCOL = "grpc"). # The default OTLP/HTTP transport needs no extra: its exporter is a base dependency. otel-grpc = ["opentelemetry-exporter-otlp-proto-grpc==1.43.0"] @@ -113,13 +125,11 @@ pr-agent = "pr_agent.cli:run" [dependency-groups] # 'similar issue' tool. Install with: uv sync --group similar-issue. -# Keep this a dependency group rather than a published extra: pinecone-datasets -# resolves from a git fork, which wheel metadata cannot reference (PyPI rejects -# direct URL deps), so a pip-installed extra would silently pull the wrong package. +# Keep this a dependency group rather than a published extra so the vector +# database drivers stay out of the base install. similar-issue = [ "pandas", # imported directly by the qdrant indexing path - "pinecone-client", - "pinecone-datasets @ git+https://github.com/mrT23/pinecone-datasets.git@main", + "pinecone==10.0.0", "lancedb==0.38.0", "qdrant-client==1.15.1", ] @@ -129,14 +139,19 @@ dev = [ "pytest-cov==7.1.0", "httpx==0.28.1", "pre-commit>=4,<5", - "ruff==0.16.5", + "ruff==0.16.8", ] +[tool.uv] +# Keep this exact pin in sync with docker/Dockerfile and docker/Dockerfile.lambda +# so local, CI, release, and container builds use the same uv version. +required-version = "==0.12.10" + [tool.ruff] line-length = 120 lint.select = [ - "E", # Pyflakes + "E", # pycodestyle "F", # Pyflakes "B", # flake8-bugbear "I", # isort @@ -154,20 +169,13 @@ lint.unfixable = [ lint.exclude = ["api/code_completions"] -# B008 is a long-standing ignore. Everything below it is a pre-existing +# Everything below is a pre-existing # violation deferred so ruff runs green on adoption — fix the code and drop # the entry. TODO: burn this list down. lint.ignore = [ - "B008", # function call in argument defaults "E402", # import not at top of file "E501", # line too long - "E711", # comparison to None "E722", # bare `except` - "F811", # redefinition of unused name - "F841", # unused local variable - "B006", # mutable argument default - "B007", # unused loop control variable - "B011", # `assert False` "B017", # assertRaises(Exception) "B023", # function definition uses loop variable "B027", # empty method without @abstractmethod @@ -177,10 +185,6 @@ lint.ignore = [ [tool.ruff.lint.per-file-ignores] # Ignore import-position and unused-import in __init__.py (re-export modules). "__init__.py" = ["E402", "F401"] -[tool.bandit] -exclude_dirs = ["tests"] -skips = ["B101"] -tests = [] [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/scripts/generate_config_reference.py b/scripts/generate_config_reference.py new file mode 100644 index 0000000000..f66229b52e --- /dev/null +++ b/scripts/generate_config_reference.py @@ -0,0 +1,232 @@ +"""Generate docs/docs/usage-guide/configuration_reference.md from configuration.toml. + +The generated page lists every active option in pr_agent/settings/configuration.toml, +grouped by section, rendering each default value and its inline comment so the docs +stay in sync with the authoritative source. Run from the repo root: + + python scripts/generate_config_reference.py [output_path] + +The output path defaults to docs/docs/usage-guide/configuration_reference.md. +""" + +from __future__ import annotations + +import re +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CONFIG_TOML = ROOT / "pr_agent/settings/configuration.toml" +DEFAULT_OUTPUT = ROOT / "docs/docs/usage-guide/configuration_reference.md" + +SOURCE_URL = "https://github.com/the-pr-agent/pr-agent/blob/main/pr_agent/settings/configuration.toml" + +PAGE_HEADER = f"""# Configuration Reference + +> This page is **auto-generated** and should not be edited by hand. +> Regenerate it from the [TOML source]({SOURCE_URL}) with: +> +> ```bash +> python scripts/generate_config_reference.py +> ``` + +Every configuration option PR-Agent supports, grouped by section. The [configuration.toml]({SOURCE_URL}) +file is the single source of truth for defaults and inline comments; this page renders the same +list for easy searching and linking. + +Rows with an empty **Description** are keys whose TOML entry carries no explanatory comment yet. +They are listed deliberately rather than hidden, so the gaps double as the documentation +to-do list. +""" + +TABLE_HEADER = "| Key | Default | Description |\n| --- | --- | --- |" + +# Comment lines shorter than this without trailing sentence punctuation are treated as +# group labels (e.g. "# models") rather than descriptions of the following key. +LABEL_MAX_LEN = 60 + +KEY_RE = re.compile(r"^(?P[a-zA-Z0-9_]+)\s*=\s*(?P.*)$") +SECTION_RE = re.compile(r"^\[(?P[^\]]+)\](?:\s*#+\s*(?P.+?)\s*#*)?$") + + +def _is_assignment(text: str) -> bool: + return bool(re.match(r"^[a-zA-Z0-9_\"']+\s*=", text)) + + +def _classify_comment(text: str) -> str: + stripped = text.strip().lstrip("#").strip() + if not stripped: + return "empty" + if _is_assignment(stripped): + return "example" + if len(stripped) <= LABEL_MAX_LEN and not re.search(r"[.:!?]", stripped): + return "label" + return "desc" + + +def _probe_value(buf: str) -> tuple: + """Return (parsed_value, value_text, inline_comment) or (None, None, None).""" + idx = buf.find("#") + if idx != -1 and buf[:idx].rstrip(): + candidate = buf[:idx].rstrip() + try: + return tomllib.loads(f"k = {candidate}")["k"], candidate, buf[idx + 1 :].strip() + except tomllib.TOMLDecodeError: + pass + try: + return tomllib.loads(f"k = {buf}")["k"], buf, None + except tomllib.TOMLDecodeError: + return None, None, None + + +def _render_value(value: object) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + if "\n" in value: + lines = [line.strip() for line in value.strip().splitlines() if line.strip()] + first = lines[0] if lines else "" + text = first + (" ..." if len(lines) > 1 else "") + return '"""' + text + '"""' + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + if isinstance(value, list): + return "[" + ", ".join(_render_value(item) for item in value) + "]" + if isinstance(value, dict): + items = ", ".join(f"{_render_value(key)} = {_render_value(item)}" for key, item in value.items()) + return "{" + items + "}" + return str(value) + + +def _escape_cell(text: str) -> str: + return text.replace("|", "\\|").replace("\n", " ") + + +def load_sections(text: str) -> list: + sections = [] + current = None + pending_comments = [] + + lines = text.splitlines() + i = 0 + while i < len(lines): + line = lines[i].strip() + + section_match = SECTION_RE.match(line) + if section_match: + current = {"name": section_match.group("name"), "tag": (section_match.group("tag") or "").strip()} + current["keys"] = [] + sections.append(current) + pending_comments = [] + i += 1 + continue + + if not line: + i += 1 + continue + + if line.startswith("#"): + if current is not None: + kind = _classify_comment(line) + if kind in ("example", "empty"): + pending_comments = [] + else: + pending_comments.append(line.lstrip("#").strip()) + i += 1 + continue + + key_match = KEY_RE.match(line) + if key_match is None or current is None: + i += 1 + continue + + buf = key_match.group("rest") + value, value_text, inline_comment = _probe_value(buf) + i += 1 + while value is None and i < len(lines): + buf += "\n" + lines[i] + value, value_text, inline_comment = _probe_value(buf) + i += 1 + if value is None: + value_text, inline_comment = buf, None + + desc = inline_comment + if desc is None: + desc_parts = [c for c in pending_comments if _classify_comment(c) == "desc"] + desc = " ".join(desc_parts) or None + label_parts = [c for c in pending_comments if _classify_comment(c) == "label"] + label = label_parts[-1] if label_parts else None + pending_comments = [] + + current["keys"].append( + { + "key": key_match.group("key"), + "value": _render_value(value) if value is not None else value_text, + "desc": desc, + "label": label, + } + ) + + return sections + + +def render_page(sections: list) -> str: + out = [PAGE_HEADER.rstrip(), ""] + + for section in sections: + keys = section["keys"] + heading = f"## `[{section['name']}]`" + if section["tag"]: + heading += f" — {section['tag']}" + out.append(heading) + out.append("") + if not keys: + out.append("_This section only documents commented-out examples; see the [TOML source]" + f"({SOURCE_URL}) for details._") + out.append("") + continue + + table_open = False + pending_label = None + for key in keys: + if key["label"]: + if table_open: + table_open = False + pending_label = "**" + key["label"] + "**" + if not table_open: + if pending_label: + out.append(pending_label) + out.append("") + pending_label = None + out.append(TABLE_HEADER) + table_open = True + desc = _escape_cell(key["desc"]) if key["desc"] else "" + value = _escape_cell(key["value"]) + out.append(f"| `{key['key']}` | {value if value.strip() else '`""`'} | {desc} |") + if table_open: + out.append("") + out.append("") + + return "\n".join(out).rstrip("\n") + "\n" + + +def main() -> int: + output = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_OUTPUT + text = CONFIG_TOML.read_text(encoding="utf-8") + sections = load_sections(text) + + rendered_keys = sum(len(section["keys"]) for section in sections) + expected_keys = len(re.findall(r"^[a-zA-Z0-9_]+\s*=", text, re.M)) + if rendered_keys != expected_keys: + print(f"ERROR: rendered {rendered_keys} keys, expected {expected_keys}", file=sys.stderr) + return 1 + + output.write_text(render_page(sections), encoding="utf-8") + print(f"Wrote {rendered_keys} keys across {len(sections)} sections to {output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000..d8ecb3c35d --- /dev/null +++ b/setup.py @@ -0,0 +1,49 @@ +from pathlib import Path +from shutil import rmtree + +from setuptools import setup +from setuptools.command.build_py import build_py as _build_py + +PROJECT_ROOT = Path(__file__).resolve().parent +HELP_DOCS_SOURCE = PROJECT_ROOT / "docs" / "docs" +HELP_DOCS_PACKAGE = Path("pr_agent") / "_help_docs" + + +class BuildPy(_build_py): + """Build the external help corpus as package resources.""" + + def _help_docs_output_mapping(self) -> dict[str, str]: + return { + str(Path(self.build_lib) / HELP_DOCS_PACKAGE / source.relative_to(HELP_DOCS_SOURCE)): str(source) + for source in sorted(HELP_DOCS_SOURCE.rglob("*.md")) + if source.is_file() + } + + def get_output_mapping(self) -> dict[str, str]: + mapping = super().get_output_mapping() + mapping.update(self._help_docs_output_mapping()) + return mapping + + def get_outputs(self, include_bytecode: bool = True) -> list[str]: + outputs = super().get_outputs(include_bytecode) + return list(dict.fromkeys([*outputs, *self._help_docs_output_mapping()])) + + def run(self) -> None: + super().run() + if getattr(self, "editable_mode", False): + return + + mapping = self._help_docs_output_mapping() + if not mapping: + raise FileNotFoundError(f"No Markdown help documents found under {HELP_DOCS_SOURCE}") + + destination = Path(self.build_lib) / HELP_DOCS_PACKAGE + if destination.exists() and not self.dry_run: + rmtree(destination) + + for output, source in mapping.items(): + self.mkpath(str(Path(output).parent)) + self.copy_file(source, output) + + +setup(cmdclass={"build_py": BuildPy}) diff --git a/tests/e2e_tests/e2e_utils.py b/tests/e2e_tests/e2e_utils.py index 57d1d2cb88..a744e25718 100644 --- a/tests/e2e_tests/e2e_utils.py +++ b/tests/e2e_tests/e2e_utils.py @@ -1,7 +1,12 @@ FILE_PATH: str = "pr_agent/cli_pip.py" -PR_HEADER_START_WITH = '### **User description**\nupdate cli_pip.py\n\n\n___\n\n### **PR Type**' -REVIEW_START_WITH = '## PR Reviewer Guide 🔍\n\n\n\n\n
    ⏱️ Estimated effort to review:' +PR_HEADER_START_WITH = ( + "### **User description**\nupdate cli_pip.py\n\n\n___\n\n### **PR Type**" +) +REVIEW_START_WITH = ( + "## PR Reviewer Guide 🔍\n\n\n\n" + "\n" + ) expected_output = textwrap.dedent(f""" {PRReviewHeader.REGULAR.value} 🔍 Here are some key observations to aid the review process:
    ⏱️ Estimated effort to review:" +) IMPROVE_START_WITH_REGEX_PATTERN = ( r"^## PR Code Suggestions ✨\n\n" r"\n\n" @@ -21,7 +26,8 @@ def main(): user_token = "..." # GitHub user token openai_key = "ghs_afsdfasdfsdf" # Example OpenAI key pr_url = "..." # PR URL, for example 'https://github.com/Codium-ai/pr-agent/pull/809' - command = "/improve" # Command to run (e.g. '/review', '/describe', 'improve', '/ask="What is the purpose of this PR?"') + command = "/improve" # Command to run (e.g. '/review', '/describe', 'improve', + # '/ask="What is the purpose of this PR?"') # Setting the configurations get_settings().set("CONFIG.git_provider", provider) diff --git a/tests/e2e_tests/test_bitbucket_app.py b/tests/e2e_tests/test_bitbucket_app.py index 26b5fa3f50..632bad6b25 100644 --- a/tests/e2e_tests/test_bitbucket_app.py +++ b/tests/e2e_tests/test_bitbucket_app.py @@ -37,7 +37,7 @@ def test_e2e_run_bitbucket_app(): # Create a new branch from the base branch logger.info(f"Creating a new branch {new_branch} from {base_branch}") source_branch = repo.branches.get(base_branch) - target_repo = repo.branches.create(new_branch,source_branch.hash) + repo.branches.create(new_branch,source_branch.hash) # Update the file content url = f"https://api.bitbucket.org/2.0/repositories/{project_key}/{repo_slug}/src" @@ -78,7 +78,7 @@ def test_e2e_run_bitbucket_app(): else: logger.info(f"Waiting for the PR to get all the tool results. {i + 1} minute(s) passed") else: - assert False, f"After {NUM_MINUTES} minutes, the PR did not get all the tool results" + raise AssertionError(f"After {NUM_MINUTES} minutes, the PR did not get all the tool results") # cleanup - delete the branch pr.decline() @@ -91,7 +91,7 @@ def test_e2e_run_bitbucket_app(): # delete the branch pr.decline() repo.branches.delete(new_branch) - assert False + raise AssertionError() if __name__ == '__main__': diff --git a/tests/e2e_tests/test_gitea_app.py b/tests/e2e_tests/test_gitea_app.py index 4a4a325f0f..50eadaed95 100644 --- a/tests/e2e_tests/test_gitea_app.py +++ b/tests/e2e_tests/test_gitea_app.py @@ -16,6 +16,37 @@ setup_logger(log_level) logger = get_logger() +def _missing_gitea_tool_results(repo_api_url, pr_number, headers): + """Check the description and the comments for the default tools' final output.""" + response = requests.get( + f"{repo_api_url}/pulls/{pr_number}", + headers=headers, + timeout=30, + ) + response.raise_for_status() + description_lines = (response.json().get("body") or "").splitlines() + missing = {"/describe", "/review", "/improve"} + if all(header in description_lines for header in ("### **PR Type**", "### **Description**")): + missing.remove("/describe") + + comment_markers = { + "/review": {""}, + "/improve": {"", ""}, + } + response = requests.get( + f"{repo_api_url}/issues/{pr_number}/comments", + headers=headers, + timeout=30, + ) + response.raise_for_status() + for comment in response.json(): + lines = set((comment.get("body") or "").splitlines()) + for command, markers in comment_markers.items(): + if lines.intersection(markers): + missing.discard(command) + return sorted(missing) + + def test_e2e_run_gitea_app(): repo_name = 'pr-agent-tests' owner = 'codiumai' @@ -25,6 +56,7 @@ def test_e2e_run_gitea_app(): headers = None pr_number = None + branch_created = False try: gitea_url = get_settings().get("GITEA.URL", None) @@ -33,12 +65,12 @@ def test_e2e_run_gitea_app(): if not gitea_url: logger.error("GITEA.URL is not set in the configuration") logger.info("Please set GITEA.URL in .env file or environment variables") - assert False, "GITEA.URL is not set in the configuration" + raise AssertionError("GITEA.URL is not set in the configuration") if not gitea_token: logger.error("GITEA.TOKEN is not set in the configuration") logger.info("Please set GITEA.TOKEN in .env file or environment variables") - assert False, "GITEA.TOKEN is not set in the configuration" + raise AssertionError("GITEA.TOKEN is not set in the configuration") headers = { 'Authorization': f'token {gitea_token}', @@ -48,53 +80,41 @@ def test_e2e_run_gitea_app(): logger.info(f"Creating a new branch {new_branch} from {base_branch}") - response = requests.get( - f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/branches/{base_branch}", - headers=headers - ) - response.raise_for_status() - base_branch_data = response.json() - base_commit_sha = base_branch_data['commit']['id'] - branch_data = { - 'ref': f"refs/heads/{new_branch}", - 'sha': base_commit_sha + 'new_branch_name': new_branch, + 'old_ref_name': base_branch } response = requests.post( - f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/git/refs", + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/branches", headers=headers, json=branch_data ) response.raise_for_status() + branch_created = True logger.info(f"Updating file {FILE_PATH} in branch {new_branch}") import base64 file_content_encoded = base64.b64encode(NEW_FILE_CONTENT.encode()).decode() - try: - response = requests.get( - f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/contents/{FILE_PATH}?ref={new_branch}", - headers=headers - ) + response = requests.get( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/contents/{FILE_PATH}?ref={new_branch}", + headers=headers + ) + file_data = { + "message": "Update cli_pip.py", + "content": file_content_encoded, + "branch": new_branch + } + if response.status_code == 404: + file_data["message"] = "Add cli_pip.py" + write_file = requests.post + else: response.raise_for_status() - existing_file = response.json() - file_sha = existing_file.get('sha') - - file_data = { - 'message': 'Update cli_pip.py', - 'content': file_content_encoded, - 'sha': file_sha, - 'branch': new_branch - } - except: - file_data = { - 'message': 'Add cli_pip.py', - 'content': file_content_encoded, - 'branch': new_branch - } - - response = requests.put( + file_data["sha"] = response.json()["sha"] + write_file = requests.put + + response = write_file( f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/contents/{FILE_PATH}", headers=headers, json=file_data @@ -117,32 +137,19 @@ def test_e2e_run_gitea_app(): pr = response.json() pr_number = pr['number'] + missing_tools = ["/describe", "/review", "/improve"] for i in range(NUM_MINUTES): logger.info("Waiting for the PR to get all the tool results...") time.sleep(60) - response = requests.get( - f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/issues/{pr_number}/comments", - headers=headers + missing_tools = _missing_gitea_tool_results( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}", pr_number, headers ) - response.raise_for_status() - comments = response.json() - - if len(comments) >= 5: - valid_review = False - for comment in comments: - if comment['body'].startswith('## PR Reviewer Guide 🔍'): - valid_review = True - break - if valid_review: - break - else: - logger.error("REVIEW feedback is invalid") - raise Exception("REVIEW feedback is invalid") - else: - logger.info(f"Waiting for the PR to get all the tool results. {i + 1} minute(s) passed") + if not missing_tools: + break + logger.info(f"Still waiting for {', '.join(missing_tools)} after {i + 1} minute(s)") else: - assert False, f"After {NUM_MINUTES} minutes, the PR did not get all the tool results" + raise AssertionError(f"After {NUM_MINUTES} minutes, missing tool results: {', '.join(missing_tools)}") logger.info(f"Cleaning up: closing PR and deleting branch {new_branch}") @@ -153,33 +160,41 @@ def test_e2e_run_gitea_app(): json=close_data ) response.raise_for_status() + pr_number = None response = requests.delete( - f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/git/refs/heads/{new_branch}", + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/branches/{new_branch}", headers=headers ) response.raise_for_status() + branch_created = False logger.info("Succeeded in running e2e test for Gitea app on the PR") except Exception as e: logger.error(f"Failed to run e2e test for Gitea app: {e}") raise finally: - try: - if headers is not None and gitea_url is not None: - if pr_number is not None: - requests.patch( + if headers is not None and gitea_url is not None: + if pr_number is not None: + try: + response = requests.patch( f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/pulls/{pr_number}", headers=headers, json={'state': 'closed'} ) - - requests.delete( - f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/git/refs/heads/{new_branch}", - headers=headers - ) - except Exception as cleanup_error: - logger.error(f"Failed to clean up after test: {cleanup_error}") + response.raise_for_status() + except Exception as cleanup_error: + logger.error(f"Failed to clean up after test: {cleanup_error}") + + if branch_created: + try: + response = requests.delete( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/branches/{new_branch}", + headers=headers + ) + response.raise_for_status() + except Exception as cleanup_error: + logger.error(f"Failed to clean up after test: {cleanup_error}") if __name__ == '__main__': test_e2e_run_gitea_app() diff --git a/tests/e2e_tests/test_github_app.py b/tests/e2e_tests/test_github_app.py index 691456b74f..0aa75c46de 100644 --- a/tests/e2e_tests/test_github_app.py +++ b/tests/e2e_tests/test_github_app.py @@ -82,7 +82,7 @@ def test_e2e_run_github_app(): else: logger.info(f"Waiting for the PR to get all the tool results. {i + 1} minute(s) passed") else: - assert False, f"After {NUM_MINUTES} minutes, the PR did not get all the tool results" + raise AssertionError(f"After {NUM_MINUTES} minutes, the PR did not get all the tool results") # cleanup - delete the branch logger.info(f"Deleting the branch {new_branch}") @@ -95,7 +95,7 @@ def test_e2e_run_github_app(): # delete the branch logger.info(f"Deleting the branch {new_branch}") repo.get_git_ref(f"heads/{new_branch}").delete() - assert False + raise AssertionError() if __name__ == '__main__': diff --git a/tests/e2e_tests/test_gitlab_webhook.py b/tests/e2e_tests/test_gitlab_webhook.py index d775b6cbde..716785e6e3 100644 --- a/tests/e2e_tests/test_gitlab_webhook.py +++ b/tests/e2e_tests/test_gitlab_webhook.py @@ -77,7 +77,7 @@ def test_e2e_run_github_app(): else: logger.info(f"Waiting for the MR to get all the tool results. {i + 1} minute(s) passed") else: - assert False, f"After {NUM_MINUTES} minutes, the MR did not get all the tool results" + raise AssertionError(f"After {NUM_MINUTES} minutes, the MR did not get all the tool results") # cleanup - delete the branch logger.info(f"Deleting the branch {new_branch}") @@ -89,7 +89,7 @@ def test_e2e_run_github_app(): logger.error(f"Failed to run e2e test for GitHub app: {e}") logger.info(f"Deleting the branch {new_branch}") project.branches.delete(new_branch) - assert False + raise AssertionError() if __name__ == '__main__': diff --git a/tests/health_test/main.py b/tests/health_test/main.py index ed753bf576..de70a7efaa 100644 --- a/tests/health_test/main.py +++ b/tests/health_test/main.py @@ -29,7 +29,7 @@ async def run_async() -> None: assert isinstance(pr_header_body, str), f"Expected artifact to be str, got {type(pr_header_body).__name__}" assert pr_header_body.startswith("###") and "PR Type" in pr_header_body and "Description" in pr_header_body, \ "PR description artifact missing expected sections" - context['settings'] = copy.deepcopy(original_settings) # Restore settings state after each test to prevent test interference + context['settings'] = copy.deepcopy(original_settings) # Restore settings after each test get_logger().info("PR description generated successfully\n") # Run the 'review' command @@ -40,7 +40,7 @@ async def run_async() -> None: assert isinstance(pr_review_body, str), f"Expected artifact to be str, got {type(pr_review_body).__name__}" assert pr_review_body.startswith("##") and "PR Reviewer Guide" in pr_review_body, \ "PR review artifact missing expected header" - context['settings'] = copy.deepcopy(original_settings) # Restore settings state after each test to prevent test interference + context['settings'] = copy.deepcopy(original_settings) # Restore settings after each test get_logger().info("PR review generated successfully\n") # Run the 'improve' command @@ -51,7 +51,7 @@ async def run_async() -> None: assert isinstance(pr_improve_body, str), f"Expected artifact to be str, got {type(pr_improve_body).__name__}" assert pr_improve_body.startswith("##") and "PR Code Suggestions" in pr_improve_body, \ "PR improve artifact missing expected header" - context['settings'] = copy.deepcopy(original_settings) # Restore settings state after each test to prevent test interference + context['settings'] = copy.deepcopy(original_settings) # Restore settings after each test get_logger().info("PR improvements generated successfully\n") get_logger().info("\n\n========\nHealth test passed successfully\n========") diff --git a/tests/health_test/test_github_smoke.py b/tests/health_test/test_github_smoke.py new file mode 100644 index 0000000000..b0d3963320 --- /dev/null +++ b/tests/health_test/test_github_smoke.py @@ -0,0 +1,116 @@ +import asyncio +import copy +import os + +from starlette_context import context, request_cycle_context + +from pr_agent.agent.pr_agent import PRAgent +from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler +from pr_agent.config_loader import get_settings, global_settings + +# The default target is linked to #2934, so the smoke run +# exercises the ticket extraction path against live data. Override with +# TEST_PR_URL; assertions specific to the default target are then skipped. +DEFAULT_PR_URL = 'https://github.com/The-PR-Agent/pr-agent/pull/2940' + +DESCRIBE_STUB_YAML = """\ +title: | + fix: guard against empty commit list in Azure DevOps get_latest_commit_url +type: Enhancement +description: | + Synthetic PR description produced by the CI smoke stub so the run needs no model key. +pr_files: + - filename: | + pr_agent/git_providers/azuredevops_provider.py + changes_title: | + Guard the empty commit list + changes_summary: | + Return an empty string instead of raising IndexError when the commit list is empty + label: | + Enhancement + - filename: | + tests/unittest/test_azure_devops_comment.py + changes_title: | + Add empty-commit regression coverage + changes_summary: | + Cover get_latest_commit_url with an empty commit list + label: | + Tests +""" + + +class StubDescribeHandler(BaseAiHandler): + """Deterministic AI handler for the GitHub smoke test. + + Returns fixed YAML valid for the /describe pipeline, so the run exercises + provider auth, diff building, language sorting, ticket extraction and prompt + rendering end to end without any model key. + """ + + def __init__(self): + self.main_pr_language = "Python" + self.calls = [] + + @property + def deployment_id(self): + return "smoke-stub" + + async def chat_completion(self, model: str, system: str, user: str, + temperature: float = 0.2, img_path: str = None): + self.calls.append((system, user)) + return DESCRIBE_STUB_YAML, "stop" + + +def _extract_diff_section(user_prompt: str) -> str: + """Return the rendered diff body between the prompt's fixed markers.""" + start_marker = "The PR Git Diff:" + end_marker = "Note that lines in the diff body are prefixed" + start = user_prompt.find(start_marker) + end = user_prompt.find(end_marker) + assert start != -1 and end != -1 and end > start, "describe prompt is missing the diff section" + return user_prompt[start + len(start_marker):end].strip("=\n ").strip() + + +async def _run_smoke() -> None: + pr_url = os.getenv('TEST_PR_URL', DEFAULT_PR_URL) + + get_settings().set("config.git_provider", "github") + get_settings().set("config.publish_output", False) + get_settings().set("config.fallback_models", []) + get_settings().set("config.propagate_tool_errors", True) + + stub = StubDescribeHandler() + agent = PRAgent(ai_handler=lambda: stub) + ok = await agent.handle_request(pr_url, ['describe']) + + assert ok, "describe command failed against the target GitHub PR" + assert stub.calls, "the AI handler was never invoked - describe did not reach the model boundary" + + system_prompt, user_prompt = stub.calls[0] + assert isinstance(system_prompt, str) and system_prompt.strip(), "describe system prompt rendered empty" + diff_section = _extract_diff_section(user_prompt) + assert diff_section, "describe prompt diff section is empty" + + if pr_url == DEFAULT_PR_URL: + assert "azuredevops_provider.py" in diff_section, \ + "describe prompt is missing the default target PR diff content" + assert "Related Ticket Info:" in user_prompt, "describe prompt is missing the related-ticket section" + assert "Ticket Title: 'Azure DevOps get_latest_commit_url" in user_prompt, \ + "expanded ticket data did not reach the describe prompt" + + artifact = dict(get_settings().data).get('artifact', '') + assert isinstance(artifact, str) and artifact.startswith("###"), \ + f"Expected markdown artifact, got {type(artifact).__name__}" + assert "PR Type" in artifact, "PR description artifact missing the PR Type section" + assert "Description" in artifact, "PR description artifact missing the Description section" + + +def test_github_describe_smoke(): + global_settings.get("config") # force the lazy load before copying settings + with request_cycle_context({}): + context['settings'] = copy.deepcopy(global_settings) + asyncio.run(_run_smoke()) + + +if __name__ == '__main__': + test_github_describe_smoke() diff --git a/tests/packaging/test_help_docs_package.py b/tests/packaging/test_help_docs_package.py new file mode 100644 index 0000000000..a49cdf2f83 --- /dev/null +++ b/tests/packaging/test_help_docs_package.py @@ -0,0 +1,338 @@ +"""End-to-end packaging regression for the question-mode /help corpus. + +Run explicitly because the default unit-test container does not include the +repository-level build inputs used here:: + + uv run pytest -q tests/packaging/test_help_docs_package.py +""" + +import json +import os +import shutil +import subprocess +import sys +import sysconfig +import tarfile +import tomllib +import zipfile +from pathlib import Path, PurePosixPath + +from pr_agent.tools.pr_help_message import _is_help_doc_included + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +RESOURCE_PREFIX = "pr_agent/_help_docs/" +SMOKE_SCRIPT = r""" +import asyncio +import json +import os +import site +import sys +from pathlib import Path +from types import SimpleNamespace + +blocked_source = Path(os.environ["BLOCKED_SOURCE"]).resolve() +sys.path[:] = [ + entry + for entry in sys.path + if not entry or Path(entry).resolve() != blocked_source +] +editable_target = os.environ.get("EDITABLE_TARGET") +if editable_target: + site.addsitedir(editable_target) +sys.path.append(os.environ["DEPENDENCY_PATH"]) + +from pr_agent.tools import pr_help_message +from pr_agent.tools.pr_help_message import PRHelpMessage + +calls = [] + +async def fake_retry(_prediction, *, model_type): + calls.append(model_type) + assert "==file name==" in tool.vars["snippets"] + assert "/tools/review.md" in tool.vars["snippets"] + return "response: ok\nrelevant_sections: []" + +pr_help_message.retry_with_fallback_models = fake_retry +tool = PRHelpMessage.__new__(PRHelpMessage) +tool.git_provider = SimpleNamespace(pr_url="https://example.com/org/repo/pull/1") +tool.ai_handler = SimpleNamespace() +tool.question_str = "How does review work?" +tool.return_as_string = False +tool.vars = {"question": tool.question_str, "snippets": ""} +asyncio.run(tool.run()) + +root = pr_help_message._get_help_docs_root() +documents = [path.as_posix() for path, _resource in pr_help_message._iter_help_docs(root)] +print(json.dumps({ + "module": pr_help_message.__file__, + "root": str(root), + "documents": documents, + "snippet_chars": len(tool.vars["snippets"]), + "model_calls": len(calls), +})) +""" +ZIP_RESOURCE_SCRIPT = r""" +import json +from importlib.resources import files + +root = files("pr_agent").joinpath("_help_docs") + +def walk(directory, prefix=""): + documents = [] + for child in directory.iterdir(): + relative = f"{prefix}/{child.name}" if prefix else child.name + if child.is_dir(): + documents.extend(walk(child, relative)) + elif child.is_file() and child.name.endswith(".md"): + assert child.read_text(encoding="utf-8") is not None + documents.append(relative) + return documents + +print(json.dumps({"root": str(root), "documents": sorted(walk(root))})) +""" + + +def _run(command: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess: + result = subprocess.run(command, cwd=cwd, env=env, text=True, capture_output=True) + assert result.returncode == 0, ( + f"Command failed ({result.returncode}): {' '.join(command)}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result + + +def _copy_repository(destination: Path) -> None: + shutil.copytree( + REPOSITORY_ROOT, + destination, + ignore=shutil.ignore_patterns(".git", ".venv", "build", "dist", "__pycache__", ".pytest_cache"), + ) + + +def _source_documents(repository: Path) -> set[str]: + docs_root = repository / "docs" / "docs" + return { + document.relative_to(docs_root).as_posix() + for document in docs_root.rglob("*.md") + if document.is_file() + } + + +def _sdist_documents(sdist: Path) -> set[str]: + with tarfile.open(sdist) as archive: + return { + name.split("/docs/docs/", 1)[1] + for name in archive.getnames() + if "/docs/docs/" in name and name.endswith(".md") + } + + +def _wheel_documents(wheel: Path) -> set[str]: + with zipfile.ZipFile(wheel) as archive: + return { + name.removeprefix(RESOURCE_PREFIX) + for name in archive.namelist() + if name.startswith(RESOURCE_PREFIX) and name.endswith(".md") + } + + +def _build_sdist(uv: str, repository: Path, output: Path, env: dict[str, str]) -> Path: + _run([uv, "build", "--sdist", "--out-dir", str(output), str(repository)], cwd=repository, env=env) + return next(output.glob("*.tar.gz")) + + +def _build_wheel(uv: str, repository: Path, output: Path, env: dict[str, str]) -> Path: + _run([uv, "build", "--wheel", "--out-dir", str(output), str(repository)], cwd=repository, env=env) + return next(output.glob("*.whl")) + + +def _run_smoke( + python: str, + temporary_root: Path, + *, + blocked_source: Path, + python_path: Path | None = None, + editable_target: Path | None = None, +) -> dict: + env = os.environ.copy() + env["BLOCKED_SOURCE"] = str(blocked_source) + env["DEPENDENCY_PATH"] = sysconfig.get_paths()["purelib"] + if python_path: + env["PYTHONPATH"] = str(python_path) + else: + env.pop("PYTHONPATH", None) + if editable_target: + env["EDITABLE_TARGET"] = str(editable_target) + else: + env.pop("EDITABLE_TARGET", None) + result = _run([python, "-S", "-c", SMOKE_SCRIPT], cwd=temporary_root, env=env) + return json.loads(result.stdout.strip().splitlines()[-1]) + + +def _run_zip_resource_smoke(python: str, temporary_root: Path, wheel: Path) -> dict: + env = os.environ.copy() + env["PYTHONPATH"] = str(wheel) + result = _run([python, "-S", "-c", ZIP_RESOURCE_SCRIPT], cwd=temporary_root, env=env) + return json.loads(result.stdout.strip().splitlines()[-1]) + + +def _install_editable( + uv: str, + repository: Path, + target: Path, + env: dict[str, str], + *, + strict: bool, +) -> None: + command = [uv, "pip", "install", "--target", str(target), "--no-deps", "--editable", str(repository)] + if strict: + command.extend(["--config-setting", "editable_mode=strict"]) + _run(command, cwd=repository, env=env) + + +def _replace_symlinks_with_hardlinks(root: Path) -> None: + for path in sorted(root.rglob("*")): + if path.is_symlink(): + source = path.resolve() + path.unlink() + os.link(source, path) + + +def test_help_docs_distribution_contract(tmp_path): + uv = shutil.which("uv") + assert uv, "uv is required for the packaging regression" + with (REPOSITORY_ROOT / "pyproject.toml").open("rb") as pyproject: + required_uv = tomllib.load(pyproject)["tool"]["uv"]["required-version"].removeprefix("==") + assert _run([uv, "--version"], cwd=tmp_path).stdout.startswith(f"uv {required_uv} ") + + build_env = os.environ.copy() + build_env["UV_CACHE_DIR"] = str(tmp_path / "uv-cache") + source_copy = tmp_path / "source" + _copy_repository(source_copy) + expected_documents = _source_documents(source_copy) + assert expected_documents + expected_runtime_documents = { + document for document in expected_documents if _is_help_doc_included(PurePosixPath(document)) + } + + sdist = _build_sdist(uv, source_copy, tmp_path / "sdist", build_env) + assert _sdist_documents(sdist) == expected_documents + + extracted = tmp_path / "extracted" + extracted.mkdir() + with tarfile.open(sdist) as archive: + archive.extractall(extracted, filter="data") + sdist_source = next(extracted.iterdir()) + wheel = _build_wheel(uv, sdist_source, tmp_path / "wheel", build_env) + assert _wheel_documents(wheel) == expected_documents + + installed = tmp_path / "installed" + _run([uv, "pip", "install", "--target", str(installed), "--no-deps", str(wheel)], cwd=tmp_path, env=build_env) + installed_result = _run_smoke( + sys.executable, + tmp_path, + blocked_source=REPOSITORY_ROOT, + python_path=installed, + ) + assert Path(installed_result["module"]).is_relative_to(installed) + assert set(installed_result["documents"]) == expected_runtime_documents + assert installed_result["model_calls"] == 1 + assert installed_result["snippet_chars"] > 0 + + zip_result = _run_zip_resource_smoke(sys.executable, tmp_path, wheel) + assert ".whl/pr_agent/_help_docs" in zip_result["root"] + assert set(zip_result["documents"]) == expected_documents + + default_target = tmp_path / "editable-default" + _install_editable(uv, source_copy, default_target, build_env, strict=False) + default_result = _run_smoke( + sys.executable, + tmp_path, + blocked_source=REPOSITORY_ROOT, + editable_target=default_target, + ) + assert Path(default_result["module"]).is_relative_to(source_copy) + assert Path(default_result["root"]).is_relative_to(source_copy / "docs" / "docs") + assert set(default_result["documents"]) == expected_runtime_documents + + strict_target = tmp_path / "editable-strict" + _install_editable(uv, source_copy, strict_target, build_env, strict=True) + strict_trees = list((source_copy / "build").glob("__editable__.*")) + assert len(strict_trees) == 1 + strict_tree = strict_trees[0] + strict_resource_root = strict_tree / RESOURCE_PREFIX.removesuffix("/") + assert { + document.relative_to(strict_resource_root).as_posix() + for document in strict_resource_root.rglob("*.md") + } == expected_documents + + _replace_symlinks_with_hardlinks(strict_tree) + assert not any(path.is_symlink() for path in strict_tree.rglob("*")) + strict_result = _run_smoke( + sys.executable, + tmp_path, + blocked_source=REPOSITORY_ROOT, + editable_target=strict_target, + ) + assert Path(strict_result["module"]).is_relative_to(strict_tree) + assert Path(strict_result["root"]).is_relative_to(strict_resource_root) + assert set(strict_result["documents"]) == expected_runtime_documents + assert strict_result["model_calls"] == 1 + + first_direct_wheel = _build_wheel(uv, source_copy, tmp_path / "first-direct-wheel", build_env) + assert _wheel_documents(first_direct_wheel) == expected_documents + + removed_document = source_copy / "docs" / "docs" / "summary.md" + assert removed_document.exists() + removed_document.unlink() + rebuilt_wheel = _build_wheel(uv, source_copy, tmp_path / "rebuilt-wheel", build_env) + assert _wheel_documents(rebuilt_wheel) == expected_documents - {"summary.md"} + + +def test_lambda_image_materializes_only_help_markdown_in_shared_base(): + dockerfile = (REPOSITORY_ROOT / "docker" / "Dockerfile.lambda").read_text(encoding="utf-8") + + mount = "RUN --mount=type=bind,source=docs/docs,target=/tmp/help-docs-source" + assert mount in dockerfile + assert f"{mount},rw" not in dockerfile + assert f"{mount},readwrite" not in dockerfile + assert "COPY docs/docs" not in dockerfile + assert "find /tmp/help-docs-source -type f -name '*.md'" in dockerfile + assert 'test "$source_count" -gt 0' in dockerfile + assert 'test "$destination_count" -eq "$source_count"' in dockerfile + assert "-type f ! -name '*.md' -print -quit" in dockerfile + assert "FROM base AS github_lambda" in dockerfile + assert "FROM base AS gitlab_lambda" in dockerfile + + +def test_lambda_layout_loads_materialized_help_docs(tmp_path): + lambda_root = tmp_path / "lambda" + shutil.copytree(REPOSITORY_ROOT / "pr_agent", lambda_root / "pr_agent") + resource_root = lambda_root / RESOURCE_PREFIX.removesuffix("/") + expected_documents = _source_documents(REPOSITORY_ROOT) + + for relative_name in expected_documents: + source = REPOSITORY_ROOT / "docs" / "docs" / relative_name + destination = resource_root / relative_name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + materialized_documents = { + document.relative_to(resource_root).as_posix() + for document in resource_root.rglob("*") + if document.is_file() + } + assert materialized_documents == expected_documents + + result = _run_smoke( + sys.executable, + tmp_path, + blocked_source=REPOSITORY_ROOT, + python_path=lambda_root, + ) + assert Path(result["module"]).is_relative_to(lambda_root) + assert Path(result["root"]).is_relative_to(resource_root) + assert set(result["documents"]) == { + document for document in expected_documents if _is_help_doc_included(PurePosixPath(document)) + } + assert result["model_calls"] == 1 diff --git a/tests/unittest/test_add_docs_missing_key.py b/tests/unittest/test_add_docs_missing_key.py new file mode 100644 index 0000000000..e168e211a4 --- /dev/null +++ b/tests/unittest/test_add_docs_missing_key.py @@ -0,0 +1,99 @@ +"""Clear the progress comment when an /add_docs response carries no documentation.""" +import asyncio + +import pytest + +from pr_agent.config_loader import get_settings +from pr_agent.tools.pr_add_docs import PRAddDocs + +DOCUMENTED = """Code Documentation: +- relevant file: | + src/app.py + relevant line: 1 + doc placement: | + before + documentation: | + \"\"\"Do the thing.\"\"\" +""" + + +class FakeGitProvider: + def __init__(self): + self.comments = [] + self.suggestions = [] + self.initial_comment_removed = False + self.diff_files = [] + + def publish_comment(self, body, **kwargs): + self.comments.append(body) + return "comment" + + def remove_initial_comment(self): + self.initial_comment_removed = True + + def publish_code_suggestions(self, suggestions): + self.suggestions.append(suggestions) + return True + + def get_diff_files(self): + return self.diff_files + + +@pytest.fixture +def publish_output(): + settings = get_settings(use_context=False) + original = settings.get("config.publish_output", True) + settings.set("config.publish_output", True) + yield settings + settings.set("config.publish_output", original) + + +def run(prediction, monkeypatch): + async def fake_retry(fn=None, model_type=None, **kwargs): + return prediction + + monkeypatch.setattr("pr_agent.tools.pr_add_docs.retry_with_fallback_models", fake_retry) + tool = PRAddDocs.__new__(PRAddDocs) + tool.git_provider = FakeGitProvider() + tool.prediction = prediction + asyncio.run(tool.run()) + return tool.git_provider + + +def results(provider): + return [c for c in provider.comments if "Generating Documentation" not in c] + + +def test_publish_the_documented_response(publish_output, monkeypatch): + """Keep publishing suggestions for a well-formed response.""" + provider = run(DOCUMENTED, monkeypatch) + + assert provider.suggestions and provider.suggestions[0] + assert provider.initial_comment_removed + + +@pytest.mark.parametrize("prediction, reason", [ + ("No documentation needed for this PR.\n", "the model answered in prose"), + ("documentation:\n- relevant file: src/app.py\n", "the model renamed the key"), + ("Code Documentation:\n", "the model emitted the key with no value"), + ("::: not : valid : yaml :::\n\t- [", "the response could not be parsed at all"), +]) +def test_clear_the_progress_comment_when_nothing_was_produced(publish_output, monkeypatch, + prediction, reason): + """The 'Generating Documentation...' placeholder must not be left behind.""" + provider = run(prediction, monkeypatch) + + assert provider.initial_comment_removed, reason + + +@pytest.mark.parametrize("prediction", [ + "No documentation needed for this PR.\n", + "documentation:\n- relevant file: src/app.py\n", + "Code Documentation:\n", + "::: not : valid : yaml :::\n\t- [", +]) +def test_tell_the_user_that_nothing_was_produced(publish_output, monkeypatch, prediction): + """A command that ran must leave a result, never just a stale placeholder.""" + provider = run(prediction, monkeypatch) + + assert results(provider) == ["No code documentation found to improve this PR."] diff --git a/tests/unittest/test_apply_repo_settings.py b/tests/unittest/test_apply_repo_settings.py index ad8ab37d83..583bfe2519 100644 --- a/tests/unittest/test_apply_repo_settings.py +++ b/tests/unittest/test_apply_repo_settings.py @@ -92,6 +92,71 @@ def test_repo_without_toml_does_not_inherit_previous_repo_settings( assert "MARKER-FROM-REPO-A" not in _extra_instructions("pr_code_suggestions"), \ "repo A's [pr_code_suggestions].extra_instructions leaked into repo B" + def test_repo_settings_cannot_raise_sibling_context_cap(self, fresh_global_settings, monkeypatch): + """A repo's .pr_agent.toml must not be able to raise + repo_context_max_sibling_files: it bounds cross-repo fetches per build, + so it is host-only. Repo file selection (repo_context_files) may still + be set from the repo, only the cap is refused. + """ + repo_toml = b""" +[config] +repo_context_files = [{repo_id = "group/A/idea", file_path = "AGENTS.md"}] +repo_context_max_sibling_files = 1000 +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: FakeGitProvider(repo_toml), + ) + with request_cycle_context({}): + context["settings"] = copy.deepcopy(global_settings) + git_utils.apply_repo_settings("https://git.example/projects/A/repos/a/pull-requests/1") + assert get_settings().config.get("repo_context_max_sibling_files") == 5, \ + "Repo settings must not be able to raise repo_context_max_sibling_files" + assert get_settings().config.get("repo_context_files") == [ + {"repo_id": "group/A/idea", "file_path": "AGENTS.md"}, + ], "Repo settings should still be able to select repo_context_files" + + def test_repo_settings_cannot_set_description_issue_regex(self, fresh_global_settings, monkeypatch): + """A repo's .pr_agent.toml must not be able to choose the pattern that is + compiled and run over the whole pull-request description: an ambiguous one + backtracks exponentially. Ordinary [config] keys still apply. + """ + repo_toml = b""" +[config] +description_issue_regex = '(?:[A-Za-z ]+)+X(\\d+)' +model = "gpt-4o" +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: FakeGitProvider(repo_toml), + ) + with request_cycle_context({}): + context["settings"] = copy.deepcopy(global_settings) + git_utils.apply_repo_settings("https://git.example/projects/A/repos/a/pull-requests/1") + assert get_settings().config.get("description_issue_regex") != "(?:[A-Za-z ]+)+X(\\d+)", \ + "Repo settings must not be able to set description_issue_regex" + assert get_settings().config.get("model") == "gpt-4o", \ + "Repo settings should still be able to set an ordinary [config] key" + + def test_repo_selects_siblings_without_changing_host_allowlist(self, fresh_global_settings, monkeypatch): + repo_toml = b""" +[config] +repo_context_sibling_repos = ["group/private"] +repo_context_files = [{repo_id = "group/approved", file_path = "api.py"}] +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: FakeGitProvider(repo_toml), + ) + with request_cycle_context({}): + context["settings"] = copy.deepcopy(global_settings) + get_settings().set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", ["group/approved"]) + git_utils.apply_repo_settings("https://git.example/projects/A/repos/a/pull-requests/1") + assert get_settings().config.repo_context_sibling_repos == ["group/approved"] + assert get_settings().config.repo_context_files == [ + {"repo_id": "group/approved", "file_path": "api.py"}, + ] + def test_unknown_section_does_not_leak_to_next_repo(self, fresh_global_settings, monkeypatch): """Catches the case where a repo's `.pr_agent.toml` introduces a section name not present in the startup defaults. With the per-request clone, diff --git a/tests/unittest/test_attempt_token_budget.py b/tests/unittest/test_attempt_token_budget.py new file mode 100644 index 0000000000..4365b26b0c --- /dev/null +++ b/tests/unittest/test_attempt_token_budget.py @@ -0,0 +1,519 @@ +from dataclasses import replace +from types import SimpleNamespace + +import pytest + +import pr_agent.algo.token_budget as token_budget_module +import pr_agent.algo.token_handler as token_handler_module +from pr_agent.algo.ai_handlers.langchain_ai_handler import LangChainOpenAIHandler +from pr_agent.algo.ai_handlers.openai_ai_handler import OpenAIHandler + + +class FakeTokenHandler: + def __init__(self, prompt_tokens=0): + self.prompt_tokens = prompt_tokens + self.counted = [] + + def count_tokens(self, text): + self.counted.append(text) + return len(text) + + +@pytest.fixture +def token_settings(monkeypatch): + settings = SimpleNamespace( + config=SimpleNamespace(model="primary-model"), + get=lambda key, default=None: { + "config.model_token_count_estimate_factor": 0, + "config.image_input_token_allowance": 4096, + "openai.key": "test-key", + "anthropic.key": None, + }.get(key, default), + ) + monkeypatch.setattr(token_handler_module, "get_settings", lambda use_context=True: settings) + monkeypatch.setattr( + token_handler_module.TokenEncoder, + "get_token_encoder", + lambda model=None: SimpleNamespace( + model=model, + encode=lambda text, disallowed_special=(): list(text), + decode=lambda tokens: "".join(tokens), + ), + ) + return settings + + +def test_for_attempt_uses_requested_window_and_preserves_fake_handler(monkeypatch): + calls = [] + fake_handler = FakeTokenHandler(prompt_tokens=17) + + def get_window(model, ignore_max_model_tokens=False): + calls.append((model, ignore_max_model_tokens)) + return 12_345 + + monkeypatch.setattr(token_budget_module, "get_max_tokens", get_window) + + budget = token_budget_module.AttemptTokenBudget.for_attempt( + "fallback-model", + fake_handler, + ignore_max_model_tokens=True, + ) + + assert budget.model == "fallback-model" + assert budget.source_token_handler is fake_handler + assert budget.token_handler is fake_handler + assert budget.context_window == 12_345 + assert budget.prompt_tokens == 17 + assert calls == [("fallback-model", True)] + + +def test_for_attempt_binds_real_handler_without_mutating_source(monkeypatch, token_settings): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 10_000) + variables = {"title": "PR"} + source = token_handler_module.TokenHandler(object(), variables, "system {{ title }}", "user") + + budget = token_budget_module.AttemptTokenBudget.for_attempt("fallback-model", source) + + assert source.model == "primary-model" + assert budget.source_token_handler is source + assert budget.token_handler is not source + assert budget.token_handler.model == "fallback-model" + assert budget.token_handler.vars is variables + assert budget.prompt_tokens == len("system PR") + len("user") + + +def test_for_prompt_attempt_counts_content_and_request_framing(monkeypatch, token_settings): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 10_000) + variables = {"title": "PR", "diff": ""} + + budget = token_budget_module.AttemptTokenBudget.for_prompt_attempt( + "fallback-model", + object(), + variables, + "system {{ title }}", + "user {{ diff }}", + ai_handler=object(), + ) + + assert budget.token_handler.model == "fallback-model" + assert budget.token_handler.vars is variables + assert budget.prompt_tokens == ( + len("system PRuser ") + + 2 * token_budget_module.MESSAGE_FRAMING_TOKEN_ALLOWANCE + + token_budget_module.REPLY_FRAMING_TOKEN_ALLOWANCE + ) + + +def test_reserves_are_resolved_independently_for_each_default(monkeypatch): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 10_000) + calls = [] + + def reserve(model, default): + calls.append((model, default)) + return default + 500 + + budget = token_budget_module.AttemptTokenBudget.for_attempt( + "openrouter/model", + FakeTokenHandler(), + output_token_reserve=reserve, + ) + + assert budget.resolve_output_reserve(1_500, preserve_minimum=True) == 2_000 + assert budget.resolve_output_reserve(1_000, preserve_minimum=True) == 1_500 + assert calls == [("openrouter/model", 1_500), ("openrouter/model", 1_000)] + + +@pytest.mark.parametrize( + ("reported", "preserve_minimum", "expected"), + [ + (100, False, 100), + (100, True, 1_500), + (1_200, True, 1_500), + (1_500, True, 1_500), + (5_000, True, 5_000), + ], +) +def test_reserve_floor_is_an_explicit_consumer_policy(monkeypatch, reported, preserve_minimum, expected): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 10_000) + budget = token_budget_module.AttemptTokenBudget.for_attempt( + "model", + FakeTokenHandler(), + output_token_reserve=lambda _model, _default: reported, + ) + + assert ( + budget.resolve_output_reserve( + 1_500, + preserve_minimum=preserve_minimum, + ) + == expected + ) + + +def test_available_tokens_subtracts_prompt_once_and_clamps(monkeypatch): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 1_000) + budget = token_budget_module.AttemptTokenBudget.for_attempt( + "model", + FakeTokenHandler(prompt_tokens=100), + output_token_reserve=lambda _model, _default: 200, + ) + + assert budget.available_tokens(1_500) == 700 + assert budget.available_tokens(1_500, prompt_tokens=0) == 800 + assert budget.available_tokens(1_500, prompt_tokens=900) == 0 + + +def test_input_token_limit_uses_attempt_reserve_and_extra_headroom(monkeypatch): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 1_000) + budget = token_budget_module.AttemptTokenBudget.for_attempt( + "model", + FakeTokenHandler(), + output_token_reserve=lambda _model, _default: 250, + ) + + assert budget.input_token_limit(100, additional_input_reserve=50) == 700 + assert budget.input_token_limit(300, preserve_minimum=True) == 700 + assert budget.input_token_limit(100, additional_input_reserve=True) == 750 + + +def test_require_input_capacity_rejects_an_exhausted_attempt(): + handler = FakeTokenHandler(prompt_tokens=900) + budget = token_budget_module.AttemptTokenBudget("small-model", handler, handler, 1_000) + + assert budget.require_input_capacity(99) == 1 + with pytest.raises(ValueError, match="leaves no input capacity"): + budget.require_input_capacity(100) + + +@pytest.mark.parametrize( + ("window", "reserve", "prompt", "expected"), + [(1_000, 1_200, 0, -200), (1_000, 900, 200, -100), (1_000, 900, 100, 0), (1_000, 900, 99, 1)], +) +def test_raw_capacity_distinguishes_exhausted_from_exact_boundary(window, reserve, prompt, expected): + handler = FakeTokenHandler(prompt_tokens=prompt) + budget = token_budget_module.AttemptTokenBudget( + "model", + handler, + handler, + window, + output_token_reserve=lambda model, default: reserve, + ) + + assert budget.available_tokens(1_500, clamp=False) == expected + assert budget.available_tokens(1_500) == max(expected, 0) + + +def test_frozen_budget_can_refresh_callback_without_rebinding(monkeypatch): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 10_000) + source = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget.for_attempt( + "model", + source, + output_token_reserve=lambda _model, _default: 1_500, + ) + + refreshed = replace( + budget, + output_token_reserve=lambda _model, _default: 5_000, + ) + + assert refreshed is not budget + assert refreshed.source_token_handler is source + assert refreshed.token_handler is budget.token_handler + assert refreshed.resolve_output_reserve(1_500) == 5_000 + + +def test_count_tokens_delegates_without_breaking_simple_fakes(monkeypatch): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 10_000) + source = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget.for_attempt("model", source) + + assert budget.count_tokens("abcd") == 4 + assert source.counted == ["abcd"] + + +@pytest.mark.parametrize("normalization", [None, ("normalized system", "normalized user")]) +def test_prepare_request_normalizes_and_counts_the_dispatched_messages(monkeypatch, normalization): + handler = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget("attempt-model", handler, handler, 1_000) + observed = [] + + class AIHandler: + def normalize_request_prompts(self, model, system_prompt, user_prompt): + observed.append((model, system_prompt, user_prompt)) + if normalization is None: + raise RuntimeError("provider normalization unavailable") + return normalization + + prepared = budget.prepare_request(AIHandler(), "system", "user", optional_text="diff") + + expected_prompts = normalization or ("system", "user") + assert (prepared.system_prompt, prepared.user_prompt) == expected_prompts + assert prepared.optional_text == "diff" + assert prepared.input_tokens == ( + sum(map(len, expected_prompts)) + + 2 * token_budget_module.MESSAGE_FRAMING_TOKEN_ALLOWANCE + + token_budget_module.REPLY_FRAMING_TOKEN_ALLOWANCE + ) + assert observed[0] == ("attempt-model", "system", "user") + assert handler.counted == list(expected_prompts) + + +def test_prepare_request_counts_image_message_with_conservative_floor(monkeypatch): + handler = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget("attempt-model", handler, handler, 10_000) + settings = SimpleNamespace( + get=lambda key, default=None: { + "config.image_input_token_allowance": 4096, + }.get(key, default) + ) + + monkeypatch.setattr(token_budget_module, "get_settings", lambda: settings) + + prepared = budget.prepare_request( + object(), + "system", + "user", + image_path="https://example.test/image.png", + ) + + assert handler.counted == ["system", "user", "https://example.test/image.png"] + assert prepared.input_tokens == ( + len("systemuserhttps://example.test/image.png") + + 2 * token_budget_module.MESSAGE_FRAMING_TOKEN_ALLOWANCE + + token_budget_module.REPLY_FRAMING_TOKEN_ALLOWANCE + + 4096 + ) + + +def test_prepare_request_counts_the_handler_final_message_shape(monkeypatch): + handler = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget("attempt-model", handler, handler, 10_000) + class AIHandler: + def build_request_messages(self, model, system_prompt, user_prompt, *, image_path=None): + assert model == "attempt-model" + assert image_path is None + return [{"role": "user", "content": f"{system_prompt}\n\n\n{user_prompt}"}] + + prepared = budget.prepare_request(AIHandler(), "system", "user") + + assert prepared.input_tokens == ( + len("system\n\n\nuser") + + token_budget_module.MESSAGE_FRAMING_TOKEN_ALLOWANCE + + token_budget_module.REPLY_FRAMING_TOKEN_ALLOWANCE + ) + assert handler.counted == ["system\n\n\nuser"] + + +@pytest.mark.parametrize("handler_class", [OpenAIHandler, LangChainOpenAIHandler]) +def test_prepare_request_counts_text_only_for_handlers_that_ignore_images( + monkeypatch, + token_settings, + handler_class, +): + handler = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget("attempt-model", handler, handler, 10_000) + monkeypatch.setattr(token_budget_module, "get_settings", lambda: token_settings) + + prepared = budget.prepare_request( + handler_class.__new__(handler_class), + "system", + "user", + image_path="https://example.test/image.png", + ) + + assert prepared.input_tokens == ( + len("systemuser") + + 2 * token_budget_module.MESSAGE_FRAMING_TOKEN_ALLOWANCE + + token_budget_module.REPLY_FRAMING_TOKEN_ALLOWANCE + ) + assert handler.counted == ["system", "user"] + + +@pytest.mark.parametrize("allowance", [None, -1, True, "4096"]) +def test_image_allowance_must_be_a_non_negative_integer(monkeypatch, allowance): + handler = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget("attempt-model", handler, handler, 10_000) + settings = SimpleNamespace( + get=lambda key, default=None: ( + allowance if key == "config.image_input_token_allowance" else default + ) + ) + monkeypatch.setattr(token_budget_module, "get_settings", lambda: settings) + + with pytest.raises(ValueError, match="image_input_token_allowance"): + budget.prepare_request( + object(), + "system", + "user", + image_path="https://example.test/image.png", + ) + + +def test_count_request_tokens_uses_attempt_tokenizer_and_ignores_estimate_factor(monkeypatch): + handler = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget("attempt-model", handler, handler, 1_000) + settings = SimpleNamespace( + get=lambda key, default=None: { + "config.model_token_count_estimate_factor": 0.25, + }.get(key, default) + ) + monkeypatch.setattr(token_budget_module, "get_settings", lambda: settings) + + count = budget.count_request_tokens("1234", "123456") + + expected = 10 + 2 * token_budget_module.MESSAGE_FRAMING_TOKEN_ALLOWANCE + expected += token_budget_module.REPLY_FRAMING_TOKEN_ALLOWANCE + assert count == expected + assert handler.counted == ["1234", "123456"] + + +def test_count_request_tokens_counts_the_provided_message_shape(): + handler = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget("attempt-model", handler, handler, 1_000) + messages = [{"role": "user", "content": "combined"}] + + assert budget.count_request_tokens("ignored", "ignored", messages=messages) == 40 + assert handler.counted == ["combined"] + + +@pytest.mark.parametrize( + ("keep", "expected_start", "expected_end"), + [("prefix", "abc", "...(truncated)\n"), ("suffix", "\n...(truncated)", "rst")], +) +def test_fit_optional_text_preserves_the_requested_side_and_exact_prompts( + monkeypatch, keep, expected_start, expected_end +): + handler = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget("attempt-model", handler, handler, 82) + + fitted = budget.fit_optional_text( + "abcdefghijklmnopqrst", + lambda optional: ("fixed", f"body:{optional}"), + ai_handler=object(), + default_output_tokens=5, + keep=keep, + ) + + assert fitted.optional_text.startswith(expected_start) + assert fitted.optional_text.endswith(expected_end) + assert fitted.system_prompt == "fixed" + assert fitted.user_prompt == f"body:{fitted.optional_text}" + assert fitted.input_tokens <= 77 + + +def test_fit_optional_text_keeps_marker_when_no_content_character_fits(monkeypatch): + handler = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget("attempt-model", handler, handler, 57) + + fitted = budget.fit_optional_text( + "abcdefghi", + lambda optional: ("s", f"u:{optional}"), + ai_handler=object(), + default_output_tokens=1, + truncation_marker="[cut]", + ) + + assert fitted.optional_text == "[cut]" + assert fitted.input_tokens == 56 + + +def test_fit_optional_text_rejects_unmarked_empty_replacement(monkeypatch): + handler = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget("attempt-model", handler, handler, 54) + + with pytest.raises(ValueError, match="truncation marker"): + budget.fit_optional_text( + "optional", + lambda optional: ("s", f"u:{optional}"), + ai_handler=object(), + default_output_tokens=1, + truncation_marker="[cut]", + ) + + +def test_fit_optional_text_truncates_only_at_attempt_token_boundaries(monkeypatch): + handler = FakeTokenHandler() + handler.encoder = SimpleNamespace( + encode=lambda text, disallowed_special=(): text.split("|"), + decode=lambda tokens: "|".join(tokens), + ) + budget = token_budget_module.AttemptTokenBudget("attempt-model", handler, handler, 75) + + fitted = budget.fit_optional_text( + "alpha|bravo|charlie|delta", + lambda optional: ("sys", optional), + ai_handler=object(), + default_output_tokens=2, + truncation_marker="[cut]", + ) + + retained = fitted.optional_text.removesuffix("[cut]").rstrip("|") + assert retained in {"alpha", "alpha|bravo", "alpha|bravo|charlie"} + assert fitted.input_tokens <= 73 + + +def test_fit_optional_text_rejects_required_prompt_that_cannot_fit(monkeypatch): + handler = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget("attempt-model", handler, handler, 58) + + with pytest.raises(ValueError, match="required prompt"): + budget.fit_optional_text( + "optional", + lambda optional: ("required-system", f"required-user:{optional}"), + ai_handler=object(), + default_output_tokens=1, + ) + + +def test_fit_optional_text_rejects_unknown_retention_policy(): + handler = FakeTokenHandler() + budget = token_budget_module.AttemptTokenBudget("attempt-model", handler, handler, 100) + + with pytest.raises(ValueError, match="retention policy"): + budget.fit_optional_text( + "optional", + lambda optional: ("system", optional), + ai_handler=object(), + default_output_tokens=10, + keep="middle", + ) + + +def test_fit_prompt_variable_does_not_mutate_shared_variables(monkeypatch, token_settings): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 78) + variables = {"diff": "", "title": "PR"} + budget = token_budget_module.AttemptTokenBudget.for_prompt_attempt( + "fallback-model", + object(), + variables, + "{{ title }}", + "{{ diff }}", + ai_handler=object(), + ) + + fitted = budget.fit_prompt_variable( + variables, + "diff", + "abcdefghijklmnopqrstuvwxyz", + ai_handler=object(), + default_output_tokens=5, + ) + + assert fitted.optional_text != "abcdefghijklmnopqrstuvwxyz" + assert variables == {"diff": "", "title": "PR"} + assert fitted.system_prompt == "PR" + assert fitted.user_prompt == fitted.optional_text + + +def test_matches_accepts_source_or_own_bound_handler_only(monkeypatch, token_settings): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda *_args, **_kwargs: 10_000) + source = token_handler_module.TokenHandler(object(), {}, "system", "user") + budget = token_budget_module.AttemptTokenBudget.for_attempt("fallback-model", source) + equivalent_handler = source.for_model("fallback-model") + + assert budget.matches("fallback-model", source) + assert budget.matches("fallback-model", budget.token_handler) + assert not budget.matches("different-model", source) + assert not budget.matches("fallback-model", equivalent_handler) + assert not budget.matches("fallback-model", object()) diff --git a/tests/unittest/test_aws_secrets_manager_logging.py b/tests/unittest/test_aws_secrets_manager_logging.py new file mode 100644 index 0000000000..44e70aa9ce --- /dev/null +++ b/tests/unittest/test_aws_secrets_manager_logging.py @@ -0,0 +1,138 @@ +"""Keep secret lookup tokens and SDK error details out of provider logs.""" + +from unittest.mock import MagicMock + +import boto3 +import pytest +from botocore.exceptions import ClientError, CredentialRetrievalError +from botocore.stub import Stubber + +import pr_agent.secret_providers.aws_secrets_manager_provider as aws_provider +from pr_agent.log import get_logger + +ERROR_SECRET = "aws-error-secret-sentinel" +WEBHOOK_TOKEN = "webhook-token-secret-sentinel" +SECRET_NAME = "test-secret" +SECRET_ARN = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret" + + +@pytest.fixture +def provider_settings(monkeypatch): + settings = { + "aws_secrets_manager.region_name": "us-east-1", + "aws_secrets_manager.secret_arn": SECRET_ARN, + } + monkeypatch.setattr(aws_provider, "get_settings", lambda: settings) + + +@pytest.fixture(params=[False, True], ids=["console", "json"]) +def provider_logs(request): + messages = [] + sink = get_logger().add( + messages.append, + filter=lambda record: record["name"] == aws_provider.__name__, + level="DEBUG", + format="{message}", + serialize=request.param, + backtrace=True, + diagnose=True, + ) + try: + yield messages + finally: + get_logger().remove(sink) + + +def assert_safe_log(messages, expected_message, level): + assert len(messages) == 1 + message = messages[0] + assert ERROR_SECRET not in str(message) + assert WEBHOOK_TOKEN not in str(message) + assert "Traceback (most recent call last)" not in str(message) + assert message.record["message"] == expected_message + assert message.record["level"].name == level + assert message.record["exception"] is None + assert not message.record["extra"] + + +@pytest.mark.parametrize("method", ["__init__", "get_secret", "get_all_secrets", "store_secret"]) +def test_sdk_error_details_are_not_logged(monkeypatch, provider_settings, provider_logs, method): + # Model the SDK error that includes stderr from a failed credential_process. + error = CredentialRetrievalError(provider="custom-process", error_msg=ERROR_SECRET) + client = MagicMock() + client_factory = MagicMock(return_value=client) + monkeypatch.setattr(aws_provider.boto3, "client", client_factory) + + if method == "__init__": + client_factory.side_effect = error + with pytest.raises(CredentialRetrievalError) as caught: + aws_provider.AWSSecretsManagerProvider() + assert caught.value is error + expected = "Failed to initialize AWS Secrets Manager Provider: CredentialRetrievalError" + else: + provider = aws_provider.AWSSecretsManagerProvider() + if method == "store_secret": + client.put_secret_value.side_effect = error + with pytest.raises(CredentialRetrievalError) as caught: + provider.store_secret(SECRET_NAME, "test-value") + assert caught.value is error + expected = f"Failed to store secret {SECRET_NAME} in AWS Secrets Manager: CredentialRetrievalError" + else: + client.get_secret_value.side_effect = error + if method == "get_secret": + assert provider.get_secret(SECRET_NAME) == "" + expected = "Failed to get secret from AWS Secrets Manager: CredentialRetrievalError" + else: + assert provider.get_all_secrets() == {} + expected = f"Failed to get secrets from AWS Secrets Manager {SECRET_ARN}: CredentialRetrievalError" + + assert ERROR_SECRET in str(error) # Do not mutate the exception returned to callers. + assert_safe_log(provider_logs, expected, "WARNING" if method == "get_secret" else "ERROR") + + +def test_unmodeled_aws_error_code_is_logged_without_message( + monkeypatch, provider_settings, provider_logs +): + error = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": ERROR_SECRET}}, + "GetSecretValue", + ) + client = MagicMock() + monkeypatch.setattr(aws_provider.boto3, "client", MagicMock(return_value=client)) + provider = aws_provider.AWSSecretsManagerProvider() + client.get_secret_value.side_effect = error + + assert provider.get_secret(SECRET_NAME) == "" + assert ERROR_SECRET in str(error) + assert_safe_log( + provider_logs, + "Failed to get secret from AWS Secrets Manager: AccessDeniedException", + "WARNING", + ) + + +def test_webhook_token_is_not_logged_on_service_error(monkeypatch, provider_settings, provider_logs): + client = boto3.session.Session().client( + "secretsmanager", + region_name="us-east-1", + aws_access_key_id="testing", + aws_secret_access_key="testing", + ) + try: + monkeypatch.setattr(aws_provider.boto3, "client", lambda *args, **kwargs: client) + provider = aws_provider.AWSSecretsManagerProvider() + with Stubber(client) as stubber: + # Keep the service error free of secrets to isolate the token-bearing SecretId. + stubber.add_client_error( + "get_secret_value", + service_error_code="InternalServiceError", + service_message="Temporary service failure", + http_status_code=500, + expected_params={"SecretId": WEBHOOK_TOKEN}, + ) + assert provider.get_secret(WEBHOOK_TOKEN) == "" + stubber.assert_no_pending_responses() + finally: + client.close() + + assert_safe_log(provider_logs, "Failed to get secret from AWS Secrets Manager: InternalServiceError", "WARNING") diff --git a/tests/unittest/test_azure_devops_incremental.py b/tests/unittest/test_azure_devops_incremental.py index 923ffc5dd1..849f463229 100644 --- a/tests/unittest/test_azure_devops_incremental.py +++ b/tests/unittest/test_azure_devops_incremental.py @@ -4,7 +4,7 @@ import pytest -from pr_agent.algo.utils import PRReviewIdentity +from pr_agent.algo.comment_identity import PRReviewIdentity from pr_agent.git_providers import AzureDevopsProvider from pr_agent.git_providers.azuredevops_provider import _AzureCommitAdapter, _to_naive_utc from pr_agent.git_providers.git_provider import IncrementalPR @@ -317,12 +317,14 @@ def test_incremental_resets_stale_diff_cache(self): # filtering/rebuild is recomputed instead of returning the full-PR diff. provider = self._make_provider() provider.diff_files = ["stale-full-diff"] + provider._pr_iteration_changes_cache = ["complete-current-iteration"] provider.azure_devops_client.get_pull_request_commits = MagicMock(return_value=[]) provider.get_issue_comments = MagicMock(return_value=[]) provider.get_incremental_commits(IncrementalPR(True)) assert provider.diff_files is None + assert provider._pr_iteration_changes_cache == ["complete-current-iteration"] class TestPrReviewerGuard: diff --git a/tests/unittest/test_azure_devops_provider.py b/tests/unittest/test_azure_devops_provider.py index c856f42785..3899550692 100644 --- a/tests/unittest/test_azure_devops_provider.py +++ b/tests/unittest/test_azure_devops_provider.py @@ -3,18 +3,32 @@ from unittest.mock import MagicMock, patch import pytest +from azure.devops.exceptions import AzureDevOpsServiceError +from pr_agent.algo.comment_identity import PRCodeSuggestionsIdentity from pr_agent.algo.inline_comment_dedup import code_fingerprint -from pr_agent.algo.types import FilePatchInfo -from pr_agent.algo.utils import PRCodeSuggestionsIdentity +from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo from pr_agent.git_providers.azuredevops_provider import ( AzureDevopsProvider, Comment, CommentThread, ) +from pr_agent.git_providers.git_provider import IncrementalPR from pr_agent.log import get_logger +def test_get_pr_branch_preserves_slashes_in_source_branch(): + provider = AzureDevopsProvider.__new__(AzureDevopsProvider) + provider.workspace_slug = "org" + provider.pr_num = 1 + provider.azure_devops_client = MagicMock() + provider.azure_devops_client.get_pull_request_by_id.return_value = SimpleNamespace( + source_ref_name="refs/heads/feature/release/v2" + ) + + assert provider.get_pr_branch() == "feature/release/v2" + + def test_publish_description_propagates_update_failure(): provider = AzureDevopsProvider.__new__(AzureDevopsProvider) provider.workspace_slug = "my-project" @@ -41,6 +55,109 @@ def test_azure_resolve_comment_thread_closes_thread(): provider.set_thread_status.assert_called_once_with(42, "closed") +def _language_provider(items): + provider = AzureDevopsProvider.__new__(AzureDevopsProvider) + provider.workspace_slug = "proj" + provider.repo_slug = "repo" + provider.pr = SimpleNamespace( + last_merge_target_commit=SimpleNamespace(commit_id="base-sha"), + last_merge_commit=SimpleNamespace(commit_id="head-sha"), + ) + provider.azure_devops_client = MagicMock() + provider.azure_devops_client.get_items.return_value = items + return provider + + +def test_get_languages_returns_language_names(): + # get_languages() must key on language NAMES (e.g. "Python"), not raw + # extensions ("py"): sort_files_by_main_languages() maps names back to + # extensions, so extension keys would drop every file into "Other". + provider = _language_provider([ + SimpleNamespace(git_object_type="blob", path="a.py"), + SimpleNamespace(git_object_type="blob", path="b.py"), + SimpleNamespace(git_object_type="blob", path="c.py"), + SimpleNamespace(git_object_type="blob", path="d.js"), + SimpleNamespace(git_object_type="blob", path="weird.zzz"), + ]) + + languages = provider.get_languages() + + # 3 Python + 1 JavaScript known; .zzz is unknown and excluded from the total. + assert languages == {"Python": 75.0, "JavaScript": 25.0} + + +def test_get_languages_queries_target_commit_inventory(): + provider = _language_provider( + [SimpleNamespace(git_object_type="blob", path="a.py")] + ) + + provider.get_languages() + + call_kwargs = provider.azure_devops_client.get_items.call_args.kwargs + assert call_kwargs["version_descriptor"].version == "base-sha" + assert call_kwargs["version_descriptor"].version_type == "commit" + + +def test_get_languages_returns_empty_map_when_target_commit_missing(): + provider = _language_provider( + [SimpleNamespace(git_object_type="blob", path="a.py")] + ) + provider.pr = SimpleNamespace(last_merge_target_commit=None) + + assert provider.get_languages() == {} + provider.azure_devops_client.get_items.assert_not_called() + + +def test_get_languages_returns_empty_map_when_target_commit_id_missing(): + provider = _language_provider( + [SimpleNamespace(git_object_type="blob", path="a.py")] + ) + provider.pr = SimpleNamespace(last_merge_target_commit=SimpleNamespace(commit_id=None)) + + assert provider.get_languages() == {} + provider.azure_devops_client.get_items.assert_not_called() + + +def test_get_languages_returns_empty_map_when_nothing_matches(): + provider = _language_provider([ + SimpleNamespace(git_object_type="blob", path="weird.zzz"), + SimpleNamespace(git_object_type="blob", path=""), + ]) + + assert provider.get_languages() == {} + + +def test_get_languages_maps_full_paths_and_multipart_extensions(): + # The matcher must classify by the basename and honor multipart extensions. + provider = _language_provider([ + SimpleNamespace(git_object_type="blob", path="src/foo.py"), + SimpleNamespace(git_object_type="blob", path="lib/bar.py"), + SimpleNamespace(git_object_type="blob", path="doc/README.md"), + SimpleNamespace(git_object_type="blob", path="notes.txt"), + SimpleNamespace(git_object_type="blob", path="tpl/file.test.ts"), + ]) + + languages = provider.get_languages() + + assert languages == {"Python": 40.0, "Markdown": 20.0, "Text": 20.0, "TypeScript": 20.0} + + +def test_get_languages_ignores_non_blob_items_and_other_languages(): + # Percentages come from the repository blob inventory, not the PR change set: + # non-blob entries (e.g. folders) must be skipped, and off-language files + # must not skew the ranking. + provider = _language_provider([ + SimpleNamespace(git_object_type="Folder", path="src"), + SimpleNamespace(git_object_type="blob", path="src/app.py"), + SimpleNamespace(git_object_type="blob", path="src/main.py"), + SimpleNamespace(git_object_type="blob", path="render.bin"), + ]) + + languages = provider.get_languages() + + assert languages == {"Python": 100.0} + + class TestAzureDevopsProviderRepoContext: def test_get_repo_file_content_reads_from_target_commit(self): # Repo-context files must be read from the PR target (base) commit, matching @@ -103,83 +220,202 @@ def test_get_repo_file_content_propagates_non_404_errors(self): class TestAzureDevopsProviderFiles: @staticmethod - def _provider(): + def _change(path="/src/app.py", change_type="edit", git_object_type="blob", **metadata): + return SimpleNamespace( + additional_properties={ + "item": {"path": path, "gitObjectType": git_object_type}, + "changeType": change_type, + **metadata, + } + ) + + @classmethod + def _provider(cls, pages=None): provider = AzureDevopsProvider.__new__(AzureDevopsProvider) provider.repo_slug = "my-repo" provider.workspace_slug = "my-project" provider.pr_num = 1 + provider.pr = SimpleNamespace( + last_merge_target_commit=SimpleNamespace(commit_id="base-sha"), + last_merge_commit=SimpleNamespace(commit_id="head-sha"), + ) provider.azure_devops_client = MagicMock() - provider.azure_devops_client.get_pull_request_commits.return_value = [SimpleNamespace(commit_id="m1")] + provider.azure_devops_client.get_pull_request_iterations.return_value = [SimpleNamespace(id=7)] + if pages is not None: + provider.azure_devops_client.get_pull_request_iteration_changes.side_effect = pages + provider.azure_devops_client.get_item.side_effect = lambda **kwargs: SimpleNamespace( + content=f"content for {kwargs['path']}\n" + ) + provider.diff_files = None + provider._diff_path_map = None + provider._pr_iteration_changes_cache = None + provider.incremental = None + provider.unreviewed_files_map = {} return provider - def test_get_files_full_skips_commits_without_changes(self): - provider = self._provider() - provider.azure_devops_client.get_pull_request_commits.return_value = [ - SimpleNamespace(commit_id="m1"), - SimpleNamespace(commit_id="m2"), - ] - provider.azure_devops_client.get_changes.side_effect = [ - SimpleNamespace(changes=None), - SimpleNamespace(changes=[{"item": {"path": "/src/app.py"}}]), - ] + def test_complete_iteration_collection_is_shared_by_file_consumers(self): + entries = [self._change(f"/src/file_{index:03}.py") for index in range(101)] + provider = self._provider([ + SimpleNamespace(change_entries=entries[:100], next_skip=100, next_top=1), + SimpleNamespace(change_entries=entries[100:], next_skip=0, next_top=0), + ]) - assert provider._get_files_full() == ["/src/app.py"] + assert provider.get_files() == [entry.additional_properties["item"]["path"] for entry in entries] + assert [file.filename for file in provider.get_diff_files()] == provider.get_files() + assert provider.azure_devops_client.get_pull_request_iteration_changes.call_count == 2 + assert provider.azure_devops_client.get_pull_request_iteration_changes.call_args_list[0].kwargs == { + "repository_id": "my-repo", + "pull_request_id": 1, + "iteration_id": 7, + "project": "my-project", + "top": 2000, + "skip": 0, + "compare_to": 0, + } + assert provider.azure_devops_client.get_pull_request_iteration_changes.call_args_list[1].kwargs["top"] == 1 + assert provider.azure_devops_client.get_pull_request_iteration_changes.call_args_list[1].kwargs["skip"] == 100 + + def test_collection_follows_continuation_beyond_max_page_size(self): + entries = [self._change(f"/src/file_{index:04}.py") for index in range(2007)] + provider = self._provider([ + SimpleNamespace(change_entries=entries[:2000], next_skip=2000, next_top=7), + SimpleNamespace(change_entries=entries[2000:], next_skip=0, next_top=0), + ]) - def test_get_files_full_skips_changes_without_paths(self): - provider = self._provider() - provider.azure_devops_client.get_changes.return_value = SimpleNamespace(changes=[ - {}, - {"item": None}, - {"item": {"path": ""}}, - {"item": {"path": "/src/app.py"}}, + assert provider._get_pr_iteration_changes() == entries + calls = provider.azure_devops_client.get_pull_request_iteration_changes.call_args_list + assert [(call.kwargs["skip"], call.kwargs["top"]) for call in calls] == [(0, 2000), (2000, 7)] + + def test_collection_handles_terminal_empty_page_and_no_iterations(self): + provider = self._provider([ + SimpleNamespace(change_entries=[], next_skip=0, next_top=0), ]) - assert provider._get_files_full() == ["/src/app.py"] + assert provider._get_pr_iteration_changes() == [] + assert provider.get_files() == [] + assert provider.azure_devops_client.get_pull_request_iteration_changes.call_count == 1 - def test_get_files_full_supports_sdk_change_objects(self): provider = self._provider() - provider.azure_devops_client.get_changes.return_value = SimpleNamespace(changes=[ - SimpleNamespace(item=SimpleNamespace(path="/src/sdk.py")), + provider.azure_devops_client.get_pull_request_iterations.return_value = [] + + assert provider._get_pr_iteration_changes() == [] + provider.azure_devops_client.get_pull_request_iteration_changes.assert_not_called() + + def test_collection_rejects_non_advancing_continuation(self): + provider = self._provider([ + SimpleNamespace(change_entries=[self._change("/first.py")], next_skip=100, next_top=100), + SimpleNamespace(change_entries=[self._change("/second.py")], next_skip=100, next_top=100), ]) - assert provider._get_files_full() == ["/src/sdk.py"] + with pytest.raises(RuntimeError, match="continuation"): + provider._get_pr_iteration_changes() - def test_get_files_full_skips_tree_entries(self): + assert provider._pr_iteration_changes_cache is None + + @pytest.mark.parametrize("next_skip,next_top", [(0, 1), (1, 0), ("1", 1), (True, 1)]) + def test_collection_rejects_malformed_continuation(self, next_skip, next_top): + provider = self._provider([ + SimpleNamespace( + change_entries=[self._change("/first.py")], + next_skip=next_skip, + next_top=next_top, + ), + ]) + + with pytest.raises(RuntimeError, match="continuation"): + provider._get_pr_iteration_changes() + + assert provider._pr_iteration_changes_cache is None + + def test_later_page_failure_does_not_cache_partial_collection(self): + entries = [self._change(f"/src/file_{index:03}.py") for index in range(101)] provider = self._provider() - provider.azure_devops_client.get_changes.return_value = SimpleNamespace(changes=[ - {"item": {"path": "/src", "gitObjectType": "tree"}}, - {"item": {"path": "/src/app.py", "gitObjectType": "blob"}}, + provider.azure_devops_client.get_pull_request_iteration_changes.side_effect = [ + SimpleNamespace(change_entries=entries[:100], next_skip=100, next_top=1), + RuntimeError("page failed"), + SimpleNamespace(change_entries=entries[:100], next_skip=100, next_top=1), + SimpleNamespace(change_entries=entries[100:], next_skip=0, next_top=0), + ] + + with pytest.raises(RuntimeError, match="page failed"): + provider._get_pr_iteration_changes() + assert provider._pr_iteration_changes_cache is None + + assert provider._get_pr_iteration_changes() == entries + assert provider.azure_devops_client.get_pull_request_iteration_changes.call_count == 4 + + def test_get_files_uses_current_iteration_and_skips_non_file_entries(self): + provider = self._provider([ + SimpleNamespace( + change_entries=[ + self._change("/src", git_object_type="tree"), + self._change(""), + self._change("/src/current.py"), + ], + next_skip=0, + next_top=0, + ), ]) + provider.azure_devops_client.get_pull_request_commits.return_value = [SimpleNamespace(commit_id="old")] + provider.azure_devops_client.get_changes.return_value = SimpleNamespace( + changes=[ + {"item": {"path": "/src/reverted.py"}}, + {"item": {"path": "/src/current.py"}}, + ] + ) - assert provider._get_files_full() == ["/src/app.py"] + assert provider.get_files() == ["/src/current.py"] + provider.azure_devops_client.get_pull_request_commits.assert_not_called() + provider.azure_devops_client.get_changes.assert_not_called() + + @classmethod + def _provider_with_change(cls, change, *get_item_results, incremental=None): + provider = cls._provider([ + SimpleNamespace(change_entries=[change], next_skip=0, next_top=0), + ]) + provider.azure_devops_client.get_item.side_effect = get_item_results + provider.incremental = incremental + return provider @staticmethod def _provider_with_pull_request_diff(*get_item_results): - provider = AzureDevopsProvider.__new__(AzureDevopsProvider) - provider.repo_slug = "my-repo" - provider.workspace_slug = "my-project" - provider.pr_num = 1 - provider.pr = SimpleNamespace( - last_merge_target_commit=SimpleNamespace(commit_id="base-sha"), - last_merge_commit=SimpleNamespace(commit_id="head-sha"), + change = TestAzureDevopsProviderFiles._change() + return TestAzureDevopsProviderFiles._provider_with_change(change, *get_item_results) + + @staticmethod + def _status_error(status_code): + error = Exception(f"Operation returned a {status_code} status code.") + error.status_code = status_code + return error + + @staticmethod + def _azure_item_not_found_error(): + wrapped_error = SimpleNamespace( + inner_exception=None, + message="The specified item does not exist at the specified version.", + exception_id=None, + type_name="Microsoft.TeamFoundation.Git.Server.GitItemNotFoundException", + type_key="GitItemNotFoundException", + error_code=0, + event_id=0, + custom_properties=None, ) - provider.azure_devops_client = MagicMock() - client = provider.azure_devops_client - client.get_pull_request_iterations.return_value = [SimpleNamespace(id=1)] - client.get_pull_request_iteration_changes.return_value = SimpleNamespace( - change_entries=[ - SimpleNamespace( - additional_properties={ - "item": {"path": "/src/app.py"}, - "changeType": "edit", - } - ) - ] + return AzureDevOpsServiceError(wrapped_error) + + @classmethod + def _provider_with_incremental_rename(cls, *get_item_results): + incremental = IncrementalPR(True) + incremental.last_seen_commit = SimpleNamespace(sha="last-seen-sha") + provider = cls._provider_with_change( + cls._change( + path="/new/name.py", + change_type="rename", + originalPath="/old/name.py", + ), + *get_item_results, + incremental=incremental, ) - client.get_item.side_effect = get_item_results - provider.diff_files = None - provider.incremental = None - provider.unreviewed_files_map = {} + provider.unreviewed_files_map = {"/new/name.py": "/new/name.py"} return provider def test_get_diff_files_keeps_file_when_new_content_fetch_fails(self): @@ -220,6 +456,342 @@ def test_get_diff_files_keeps_file_when_original_content_fetch_fails(self): assert diff_files[0].base_file == "" assert any("/src/app.py" in message and "base-sha" in message for message in captured) + def test_pure_rename_uses_old_path_and_preserves_identity(self): + change = self._change( + path="/new/name.py", + change_type="rename", + originalPath="/old/name.py", + ) + provider = self._provider_with_change( + change, + SimpleNamespace(content="same content\n"), + SimpleNamespace(content="same content\n"), + ) + + diff_file = provider.get_diff_files()[0] + + assert diff_file.filename == "/new/name.py" + assert diff_file.old_filename == "/old/name.py" + assert diff_file.edit_type == EDIT_TYPE.RENAMED + assert diff_file.base_file == diff_file.head_file == "same content\n" + assert diff_file.patch == "" + assert diff_file.num_plus_lines == 0 + assert diff_file.num_minus_lines == 0 + calls = provider.azure_devops_client.get_item.call_args_list + assert calls[0].kwargs["path"] == "/new/name.py" + assert calls[0].kwargs["version_descriptor"].version == "head-sha" + assert calls[1].kwargs["path"] == "/old/name.py" + assert calls[1].kwargs["version_descriptor"].version == "base-sha" + + def test_rename_on_later_page_preserves_old_path_and_base_content(self): + provider = self._provider([ + SimpleNamespace( + change_entries=[self._change("/src/first.py")], + next_skip=1, + next_top=1, + ), + SimpleNamespace( + change_entries=[ + self._change( + path="/new/name.py", + change_type="rename", + originalPath="/old/name.py", + ) + ], + next_skip=0, + next_top=0, + ), + ]) + + diff_files = provider.get_diff_files() + + assert [diff_file.filename for diff_file in diff_files] == ["/src/first.py", "/new/name.py"] + assert diff_files[1].old_filename == "/old/name.py" + assert diff_files[1].head_file == "content for /new/name.py\n" + assert diff_files[1].base_file == "content for /old/name.py\n" + assert provider.azure_devops_client.get_pull_request_iteration_changes.call_count == 2 + + def test_rename_with_edit_only_reports_the_actual_change_and_keeps_head_anchor(self): + change = self._change( + path="/new/name.py", + change_type="edit, rename", + originalPath="/old/name.py", + ) + provider = self._provider_with_change( + change, + SimpleNamespace(content="value = 2\n"), + SimpleNamespace(content="value = 1\n"), + ) + + diff_file = provider.get_diff_files()[0] + inline_comment = provider.create_inline_comment("body", "/new/name.py", "value = 2") + + assert diff_file.old_filename == "/old/name.py" + assert diff_file.num_plus_lines == 1 + assert diff_file.num_minus_lines == 1 + assert "+value = 2" in diff_file.patch + assert "-value = 1" in diff_file.patch + assert inline_comment["path"] == "/new/name.py" + assert inline_comment["subject_type"] == "LINE" + + @pytest.mark.parametrize( + ("change", "expected_old_path"), + [ + ( + SimpleNamespace( + original_path="/attribute/original.py", + source_server_item="/attribute/source.py", + additional_properties={ + "item": {"path": "/new/name.py"}, + "changeType": "rename", + "originalPath": "/mapping/original.py", + "sourceServerItem": "/mapping/source.py", + }, + ), + "/attribute/original.py", + ), + ( + SimpleNamespace( + original_path="", + source_server_item="/attribute/source.py", + additional_properties={ + "item": {"path": "/new/name.py"}, + "changeType": "rename", + "originalPath": "/mapping/original.py", + }, + ), + "/mapping/original.py", + ), + ( + SimpleNamespace( + additional_properties={ + "item": {"path": "/new/name.py"}, + "changeType": "rename", + "sourceServerItem": "/mapping/source.py", + }, + ), + "/mapping/source.py", + ), + ( + SimpleNamespace( + item=SimpleNamespace(path="/new/name.py"), + change_type="rename", + source_server_item="/attribute/source.py", + additional_properties={}, + ), + "/attribute/source.py", + ), + ], + ) + def test_rename_old_path_metadata_precedence(self, change, expected_old_path): + provider = self._provider_with_change( + change, + SimpleNamespace(content="new\n"), + SimpleNamespace(content="old\n"), + ) + + diff_file = provider.get_diff_files()[0] + + assert diff_file.old_filename == expected_old_path + assert provider.azure_devops_client.get_item.call_args_list[1].kwargs["path"] == expected_old_path + + @pytest.mark.parametrize( + "metadata", + [ + {}, + {"originalPath": {"path": "/old/name.py"}, "sourceServerItem": 17}, + {"originalPath": " ", "sourceServerItem": []}, + ], + ) + def test_rename_with_missing_or_malformed_old_path_keeps_empty_base_fallback(self, metadata): + change = self._change(path="/new/name.py", change_type="rename", **metadata) + provider = self._provider_with_change(change, SimpleNamespace(content="new\n")) + + captured = [] + sink_id = get_logger().add(lambda message: captured.append(str(message)), format="{message}") + try: + diff_file = provider.get_diff_files()[0] + finally: + get_logger().remove(sink_id) + + assert diff_file.old_filename is None + assert diff_file.base_file == "" + assert provider.azure_devops_client.get_item.call_count == 1 + assert any("/new/name.py" in message and "no usable old path" in message for message in captured) + + def test_incremental_rename_reads_old_path_at_last_seen_commit(self): + provider = self._provider_with_incremental_rename( + SimpleNamespace(content="new\n"), + SimpleNamespace(content="old\n"), + ) + + diff_file = provider.get_diff_files()[0] + + assert diff_file.old_filename == "/old/name.py" + old_content_call = provider.azure_devops_client.get_item.call_args_list[1] + assert old_content_call.kwargs["path"] == "/old/name.py" + assert old_content_call.kwargs["version_descriptor"].version == "last-seen-sha" + assert provider.unreviewed_files_map["/new/name.py"] == diff_file.patch + + def test_incremental_rename_retries_current_path_when_old_path_is_missing_at_checkpoint(self): + provider = self._provider_with_incremental_rename( + SimpleNamespace(content="value = 3\n"), + self._azure_item_not_found_error(), + SimpleNamespace(content="value = 2\n"), + ) + + diff_file = provider.get_diff_files()[0] + + assert diff_file.old_filename == "/old/name.py" + assert diff_file.edit_type == EDIT_TYPE.RENAMED + assert diff_file.base_file == "value = 2\n" + assert diff_file.head_file == "value = 3\n" + assert "-value = 2" in diff_file.patch + assert "+value = 3" in diff_file.patch + assert provider.unreviewed_files_map["/new/name.py"] == diff_file.patch + calls = provider.azure_devops_client.get_item.call_args_list + assert [call.kwargs["path"] for call in calls] == [ + "/new/name.py", + "/old/name.py", + "/new/name.py", + ] + assert [call.kwargs["version_descriptor"].version for call in calls] == [ + "head-sha", + "last-seen-sha", + "last-seen-sha", + ] + + @pytest.mark.parametrize("retry_status_code", [404, 500]) + def test_incremental_rename_uses_empty_base_when_both_checkpoint_paths_fail(self, retry_status_code): + provider = self._provider_with_incremental_rename( + SimpleNamespace(content="new\n"), + self._status_error(404), + self._status_error(retry_status_code), + ) + + captured = [] + sink_id = get_logger().add(lambda message: captured.append(str(message)), format="{message}") + try: + diff_file = provider.get_diff_files()[0] + finally: + get_logger().remove(sink_id) + + assert diff_file.old_filename == "/old/name.py" + assert diff_file.edit_type == EDIT_TYPE.RENAMED + assert diff_file.base_file == "" + assert [call.kwargs["path"] for call in provider.azure_devops_client.get_item.call_args_list] == [ + "/new/name.py", + "/old/name.py", + "/new/name.py", + ] + assert any( + "/new/name.py" in message and "last-seen-sha" in message and "retry" in message + for message in captured + ) + + def test_incremental_rename_does_not_retry_after_successful_empty_old_path_read(self): + provider = self._provider_with_incremental_rename( + SimpleNamespace(content="new\n"), + SimpleNamespace(content=""), + ) + + diff_file = provider.get_diff_files()[0] + + assert diff_file.base_file == "" + assert [call.kwargs["path"] for call in provider.azure_devops_client.get_item.call_args_list] == [ + "/new/name.py", + "/old/name.py", + ] + + def test_incremental_rename_does_not_retry_current_path_after_non_404_failure(self): + provider = self._provider_with_incremental_rename( + SimpleNamespace(content="new\n"), + self._status_error(500), + ) + + captured = [] + sink_id = get_logger().add(lambda message: captured.append(str(message)), format="{message}") + try: + diff_file = provider.get_diff_files()[0] + finally: + get_logger().remove(sink_id) + + assert diff_file.old_filename == "/old/name.py" + assert diff_file.edit_type == EDIT_TYPE.RENAMED + assert diff_file.base_file == "" + assert [call.kwargs["path"] for call in provider.azure_devops_client.get_item.call_args_list] == [ + "/new/name.py", + "/old/name.py", + ] + assert any( + "/old/name.py" in message and "last-seen-sha" in message and "500" in message + for message in captured + ) + + def test_failed_rename_old_content_read_keeps_identity_and_logs_the_failure(self): + change = self._change( + path="/new/name.py", + change_type="rename", + originalPath="/old/name.py", + ) + provider = self._provider_with_change( + change, + SimpleNamespace(content="new\n"), + Exception("base fetch failed"), + ) + + captured = [] + sink_id = get_logger().add(lambda message: captured.append(str(message)), format="{message}") + try: + diff_file = provider.get_diff_files()[0] + finally: + get_logger().remove(sink_id) + + assert diff_file.old_filename == "/old/name.py" + assert diff_file.base_file == "" + assert any("/old/name.py" in message and "base-sha" in message for message in captured) + + @pytest.mark.parametrize( + ("change_type", "get_item_results", "expected_type", "expected_base", "expected_paths"), + [ + ( + "add", + (SimpleNamespace(content="new\n"),), + EDIT_TYPE.ADDED, + "", + ["/src/app.py"], + ), + ( + "edit", + (SimpleNamespace(content="new\n"), SimpleNamespace(content="old\n")), + EDIT_TYPE.MODIFIED, + "old\n", + ["/src/app.py", "/src/app.py"], + ), + ( + "delete", + (Exception("head missing"), SimpleNamespace(content="old\n")), + EDIT_TYPE.DELETED, + "old\n", + ["/src/app.py", "/src/app.py"], + ), + ], + ) + def test_non_rename_change_handling_is_unchanged( + self, change_type, get_item_results, expected_type, expected_base, expected_paths + ): + provider = self._provider_with_change( + self._change(change_type=change_type), + *get_item_results, + ) + + diff_file = provider.get_diff_files()[0] + + assert diff_file.edit_type == expected_type + assert diff_file.old_filename is None + assert diff_file.base_file == expected_base + assert [call.kwargs["path"] for call in provider.azure_devops_client.get_item.call_args_list] == expected_paths + def _provider_with_diff(*filenames): provider = AzureDevopsProvider.__new__(AzureDevopsProvider) @@ -421,6 +993,10 @@ def test_persistent_inline_comments_mark_new_suggestion_and_deduplicate_batch(se "relevant_file": "/src/app.py", "relevant_lines_start": 1, "relevant_lines_end": 1, + "original_suggestion": { + "existing_code": "value = 2", + "improved_code": "value = 1", + }, } with patch("pr_agent.git_providers.azuredevops_provider.get_settings") as settings: @@ -538,7 +1114,7 @@ def test_persistent_inline_comments_mark_pr_level_fallback(self): provider = _provider_with_diff("/src/app.py") with (patch("pr_agent.git_providers.azuredevops_provider.get_settings") as settings, - patch("pr_agent.algo.utils.get_settings") as heading_settings): + patch("pr_agent.algo.comment_identity.get_settings") as heading_settings): settings.return_value.get.side_effect = lambda key, default=None: { "config.persistent_inline_comments": True, }.get(key, default) @@ -743,6 +1319,7 @@ def test_set_pr_invalidates_the_diff_path_index(self): provider.pr_commits = ["stale"] provider.previous_review = "stale" provider.unreviewed_files_map = {"stale.cs": "stale.cs"} + provider._pr_iteration_changes_cache = ["stale"] provider.temp_comments = ["stale"] provider._parse_pr_url = MagicMock(return_value=("project", "repo", 2)) provider._get_pr = MagicMock(return_value=MagicMock()) @@ -754,6 +1331,7 @@ def test_set_pr_invalidates_the_diff_path_index(self): assert provider.pr_commits is None assert provider.previous_review is None assert provider.unreviewed_files_map == {} + assert provider._pr_iteration_changes_cache is None assert provider.temp_comments == [] def test_unmatched_suggestion_path_does_not_break_markdown(self): @@ -1732,3 +2310,148 @@ def test_global_settings_result_is_cached(self): assert provider._get_global_repo_settings() == b"[pr_reviewer]\nnum_max_findings = 5\n" # cached assert provider.azure_devops_client.get_item_content.call_count == 1 + + +class TestAzureDevopsProviderSuggestionFence: + """Regression tests for #2110: Azure DevOps has no committable suggestion blocks, so the + ```suggestion fence /improve emits was published verbatim and rendered as a raw block. + """ + + @staticmethod + def _committable_suggestion(relevant_file="/src/app.py"): + suggestion = _suggestion(relevant_file) + suggestion["body"] = "**Suggestion:** use a set [best practice]\n```suggestion\nvalues = set()\n```" + suggestion["original_suggestion"] = { + "existing_code": "values = []", + "improved_code": "values = set()", + } + return suggestion + + def test_suggestion_fence_is_published_as_a_diff_block(self): + provider = _provider_with_diff("/src/app.py") + + provider.publish_code_suggestions([self._committable_suggestion()]) + + body = _created_threads(provider)[-1].comments[0].content + assert "```suggestion" not in body + assert "```diff" in body + assert "-values = []" in body + assert "+values = set()" in body + assert body.startswith("**Suggestion:** use a set [best practice]") + + def test_body_is_left_alone_when_there_is_no_original_suggestion(self): + provider = _provider_with_diff("/src/app.py") + + provider.publish_code_suggestions([_suggestion("/src/app.py")]) + + assert _created_threads(provider)[-1].comments[0].content == "```suggestion\nfixed\n```" + + def test_pr_level_fallback_also_carries_the_diff_block(self): + provider = _provider_with_diff("/src/app.py") + provider.publish_comment = MagicMock() + + provider.publish_code_suggestions([self._committable_suggestion("/src/removed.py")]) + + published = provider.publish_comment.call_args[0][0] + assert "```diff" in published + assert "```suggestion" not in published + + def test_unusable_original_suggestion_keeps_the_suggestion(self): + provider = _provider_with_diff("/src/app.py") + suggestion = self._committable_suggestion() + del suggestion["original_suggestion"]["improved_code"] + + provider.publish_code_suggestions([suggestion]) + + assert "```suggestion" in _created_threads(provider)[-1].comments[0].content + + def test_backslashes_in_proposed_code_are_preserved(self): + provider = _provider_with_diff("/src/app.py") + suggestion = self._committable_suggestion() + suggestion["original_suggestion"] = { + "existing_code": "print(1)", + "improved_code": "print(\t\\1\\d)", + } + + provider.publish_code_suggestions([suggestion]) + + body = _created_threads(provider)[-1].comments[0].content + assert "```diff" in body + assert "+print(\t\\1\\d)" in body + + def test_invalid_range_still_falls_back_to_pr_comment(self): + provider = _provider_with_diff("/src/app.py") + provider.publish_comment = MagicMock() + suggestion = self._committable_suggestion() + suggestion["relevant_lines_start"] = 200 + suggestion["relevant_lines_end"] = 202 + + provider.publish_code_suggestions([suggestion]) + + published = provider.publish_comment.call_args[0][0] + assert "```diff" in published + assert "```suggestion" not in published + + def test_rendered_thread_still_feeds_the_next_run(self): + provider = _provider_with_diff("/src/app.py") + suggestion = self._committable_suggestion() + suggestion["relevant_lines_start"] = suggestion["relevant_lines_end"] = 2 + with patch("pr_agent.git_providers.azuredevops_provider.get_settings") as settings: + settings.return_value.get.side_effect = lambda key, default=None: ( + True if key == "config.persistent_inline_comments" else default + ) + settings.return_value.azure_devops.get.return_value = "active" + provider.publish_code_suggestions([suggestion]) + stored = SimpleNamespace( + id=17, + status="active", + thread_context=SimpleNamespace( + file_path="/src/app.py", + right_file_start=SimpleNamespace(line=2), + right_file_end=SimpleNamespace(line=2), + ), + comments=[ + _created_threads(provider)[0].comments[0], + SimpleNamespace(content="Not doing this.", author=SimpleNamespace(display_name="Alex")), + ], + ) + provider.azure_devops_client.get_threads.return_value = [stored] + provider.pr = SimpleNamespace(last_merge_commit=SimpleNamespace(commit_id="head")) + provider.azure_devops_client.get_item.return_value = SimpleNamespace( + content="before\nvalues = set()\nafter" + ) + assert "Not doing this." in provider.get_code_suggestion_thread_context() + assert provider.reconcile_code_suggestion_threads() == 1 + + def test_rendered_thread_without_markers_is_not_reposted(self): + """A thread published before persistent_inline_comments was enabled carries no + dedup marker, but the fingerprint bootstrap must still recognize its rendered + diff block so the suggestion is not re-posted once the feature is switched on. + """ + suggestion = self._committable_suggestion() + first_run = _provider_with_diff("/src/app.py") + first_run.publish_code_suggestions([suggestion]) + stored = SimpleNamespace( + thread_context=SimpleNamespace( + file_path="/src/app.py", + right_file_start=SimpleNamespace(line=10), + right_file_end=SimpleNamespace(line=12), + ), + comments=[_created_threads(first_run)[0].comments[0]], + ) + provider = _provider_with_diff("/src/app.py") + provider.azure_devops_client.get_threads.return_value = [stored] + with patch("pr_agent.git_providers.azuredevops_provider.get_settings") as settings: + settings.return_value.get.side_effect = lambda key, default=None: ( + True if key == "config.persistent_inline_comments" else default + ) + settings.return_value.azure_devops.get.return_value = "active" + provider.publish_code_suggestions([suggestion]) + provider.azure_devops_client.create_thread.assert_not_called() + + def test_no_op_suggestion_keeps_its_fence(self): + provider = _provider_with_diff("/src/app.py") + suggestion = self._committable_suggestion() + suggestion["original_suggestion"]["improved_code"] = "values = [] " + provider.publish_code_suggestions([suggestion]) + assert "```suggestion" in _created_threads(provider)[-1].comments[0].content diff --git a/tests/unittest/test_bitbucket_inline_comment_dedup.py b/tests/unittest/test_bitbucket_inline_comment_dedup.py new file mode 100644 index 0000000000..3249ba2e92 --- /dev/null +++ b/tests/unittest/test_bitbucket_inline_comment_dedup.py @@ -0,0 +1,64 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from pr_agent.algo.inline_comment_dedup import ( + InlineCommentStore, + can_verify_inline_comment_publication, + key_issue_body_with_markers, +) +from pr_agent.git_providers.bitbucket_provider import BitbucketProvider + + +def _provider(comment_bodies): + provider = BitbucketProvider.__new__(BitbucketProvider) + provider._published_inline_comment_bodies = [] + provider.pr = MagicMock() + provider.pr.comments.return_value = [SimpleNamespace(raw=body) for body in comment_bodies] + return provider + + +def test_bitbucket_cloud_exposes_inline_dedup_capabilities(): + provider = _provider(["old inline finding"]) + + assert can_verify_inline_comment_publication(provider) + assert provider.get_persistent_comment_bodies() == ["old inline finding"] + assert provider.get_recent_inline_comment_bodies() == [] + + +def test_store_loads_bitbucket_persistent_bodies_without_failure(): + provider = _provider(["[pr-agent-dedup: abcdef123456]: https://github.com/The-PR-Agent/pr-agent"]) + store = InlineCommentStore(provider) + store.load() + assert not store.load_failed + assert store.seen("abcdef123456") + + +def test_bitbucket_cloud_dedup_bodies_include_published_comments(): + provider = _provider([]) + provider._published_inline_comment_bodies.append("new inline finding") + + assert provider.get_persistent_comment_bodies() == ["new inline finding"] + assert provider.get_recent_inline_comment_bodies() == ["new inline finding"] + + +def test_bitbucket_publish_records_body_and_preserves_markers(): + provider = _provider([]) + provider.headers = {} + provider.bitbucket_comment_api_url = "https://bitbucket.example/comments" + provider.max_comment_length = 31000 + body = key_issue_body_with_markers( + "x" * 31_500, "abcdef123456", "123456abcdef", 31_000, provider + ) + with patch("pr_agent.git_providers.bitbucket_provider.requests.request") as request: + request.return_value.raise_for_status.return_value = None + assert provider.publish_inline_comment(body, "file.py", 10) + published = provider.get_recent_inline_comment_bodies() + assert published and "pr-agent-key-issue-location: 123456abcdef" in published[0] + assert provider.get_persistent_comment_bodies() == published + + +def test_bitbucket_cloud_skips_comments_without_raw_body(): + provider = _provider([]) + provider.pr.comments.return_value = [SimpleNamespace(raw=None), SimpleNamespace(raw="valid")] + + assert provider.get_persistent_comment_bodies() == ["valid"] diff --git a/tests/unittest/test_bitbucket_provider.py b/tests/unittest/test_bitbucket_provider.py index 229749092d..c9b6cf6b2e 100644 --- a/tests/unittest/test_bitbucket_provider.py +++ b/tests/unittest/test_bitbucket_provider.py @@ -3,15 +3,17 @@ import pytest from atlassian.bitbucket import Bitbucket -from requests.exceptions import HTTPError +from requests import Request, Response +from requests.exceptions import ConnectionError as RequestsConnectionError +from requests.exceptions import HTTPError, Timeout -from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo -from pr_agent.algo.utils import ( +from pr_agent.algo.comment_identity import ( PRCodeSuggestionsHeader, PRCodeSuggestionsIdentity, PRReviewHeader, PRReviewIdentity, ) +from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo from pr_agent.git_providers import BitbucketServerProvider from pr_agent.git_providers.bitbucket_provider import BitbucketProvider from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions @@ -25,12 +27,30 @@ def _added_file(filename="src/example.py", lines=("a = 1", "b = 2", "c = 3")): class TestBitbucketProvider: @staticmethod - def _code_suggestion(line: int): + def _source_write_provider(): + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.workspace_slug = "workspace" + provider.repo_slug = "repository" + provider.headers = {"Authorization": "Bearer test-value"} + return provider + + @staticmethod + def _source_write_response(status_code: int): + url = "https://api.bitbucket.org/2.0/repositories/workspace/repository/src/" + response = Response() + response.status_code = status_code + response.url = url + response.request = Request("POST", url).prepare() + response._content = b"response-body-sentinel" + return response + + @staticmethod + def _code_suggestion(line: int, end_line: int = None): return { "body": "**Suggestion:** fix it", "relevant_file": "src/example.py", "relevant_lines_start": line, - "relevant_lines_end": line, + "relevant_lines_end": line if end_line is None else end_line, } @staticmethod @@ -103,6 +123,64 @@ def test_publish_description_accepts_success_response(self, status_code): with patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response): provider.publish_description("AI title", "Updated description") + @pytest.mark.parametrize("status_code", [200, 201, 204]) + def test_create_or_update_pr_file_accepts_success_response(self, status_code): + provider = self._source_write_provider() + response = self._source_write_response(status_code) + + with patch( + "pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response + ) as request: + result = provider.create_or_update_pr_file( + "CHANGELOG.md", "feature", "new content", "Update changelog" + ) + + assert result is None + request.assert_called_once() + assert request.call_args.args == ( + "POST", + "https://api.bitbucket.org/2.0/repositories/workspace/repository/src/", + ) + assert request.call_args.kwargs["data"] == {"message": "Update changelog", "branch": "feature"} + assert request.call_args.kwargs["files"] == {"CHANGELOG.md": "new content"} + assert set(request.call_args.kwargs["headers"]) == {"Authorization"} + + @pytest.mark.parametrize("status_code", [401, 403, 409, 500, 503]) + def test_create_or_update_pr_file_propagates_http_errors(self, status_code): + provider = self._source_write_provider() + response = self._source_write_response(status_code) + + with ( + patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response) as request, + patch("pr_agent.git_providers.bitbucket_provider.get_logger") as get_logger, + pytest.raises(HTTPError) as raised, + ): + provider.create_or_update_pr_file( + "CHANGELOG.md", "feature", "new content", "Update changelog" + ) + + assert raised.value.response is response + request.assert_called_once() + get_logger.assert_not_called() + + @pytest.mark.parametrize("error_type", [RequestsConnectionError, Timeout]) + def test_create_or_update_pr_file_propagates_transport_errors(self, error_type): + provider = self._source_write_provider() + error = error_type("transport failed") + + with ( + patch("pr_agent.git_providers.bitbucket_provider.requests.request", side_effect=error) as request, + patch("pr_agent.git_providers.bitbucket_provider.get_logger") as get_logger, + pytest.raises(error_type) as raised, + ): + provider.create_or_update_pr_file( + "CHANGELOG.md", "feature", "new content", "Update changelog" + ) + + assert raised.value is error + request.assert_called_once() + get_logger.assert_not_called() + def test_parse_pr_url(self): url = "https://bitbucket.org/WORKSPACE_XYZ/MY_TEST_REPO/pull-requests/321" workspace_slug, repo_slug, pr_number = BitbucketProvider._parse_pr_url(url) @@ -111,7 +189,16 @@ def test_parse_pr_url(self): assert pr_number == 321 @pytest.mark.parametrize( - ("status", "lines_added", "lines_removed", "filename", "old_filename", "raw_diff", "expected_patch", "edit_type"), + ( + "status", + "lines_added", + "lines_removed", + "filename", + "old_filename", + "raw_diff", + "expected_patch", + "edit_type", + ), [ ( "modified", @@ -281,6 +368,80 @@ def test_get_diff_files_warns_for_nonempty_change_without_hunks(self): "Bitbucket failed to get diff for file src/example.py" ) + def test_get_diff_files_keeps_diff_header_literals_inside_hunks(self): + raw_diff = """diff --git a/src/parser.py b/src/parser.py +index 1111111..2222222 100644 +--- a/src/parser.py ++++ b/src/parser.py +@@ -1,2 +1,2 @@ + diff --git context literal +-diff --git removed literal ++diff --git added literal +""" + + diff_file = self._get_single_diff_file( + raw_diff, + "modified", + 1, + 1, + "src/parser.py", + ) + + assert diff_file.patch == ( + "@@ -1,2 +1,2 @@\n" + " diff --git context literal\n" + "-diff --git removed literal\n" + "+diff --git added literal\n" + ) + + def test_get_diff_files_splits_crlf_headers_after_preamble_in_diffstat_order(self): + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.diff_files = None + provider.pr = MagicMock() + + first = MagicMock() + first.new.path = "src/first.py" + first.old.path = "src/first.py" + first.data = {"status": "modified", "lines_added": 1, "lines_removed": 1} + + second = MagicMock() + second.new.path = "src/second.py" + second.old.path = "src/second.py" + second.data = {"status": "modified", "lines_added": 1, "lines_removed": 1} + + diffstats = [first, second] + provider.pr.diffstat.return_value = diffstats + provider.pr.diff.return_value = ( + "Bitbucket generated diff\r\n" + "\r\n" + "diff --git a/src/first.py b/src/first.py\r\n" + "index 1111111..2222222 100644\r\n" + "--- a/src/first.py\r\n" + "+++ b/src/first.py\r\n" + "@@ -1 +1 @@\r\n" + "-old first\r\n" + "+new first\r\n" + "diff --git a/src/second.py b/src/second.py\r\n" + "index 3333333..4444444 100644\r\n" + "--- a/src/second.py\r\n" + "+++ b/src/second.py\r\n" + "@@ -1 +1 @@\r\n" + "-old second\r\n" + "+new second\r\n" + ) + + settings = MagicMock() + settings.get.return_value = True + with ( + patch("pr_agent.git_providers.bitbucket_provider.filter_ignored", return_value=diffstats), + patch("pr_agent.git_providers.bitbucket_provider.get_settings", return_value=settings), + ): + diff_files = provider.get_diff_files() + + assert [diff_file.filename for diff_file in diff_files] == ["src/first.py", "src/second.py"] + assert diff_files[0].patch.startswith("@@ -1 +1 @@\r\n-old first") + assert diff_files[1].patch.startswith("@@ -1 +1 @@\r\n-old second") + def test_get_repo_file_content_reads_from_target_branch(self): # Repo-context files must be read from the PR destination (target) branch, # matching the other providers. @@ -338,6 +499,23 @@ def test_get_pr_file_content_propagates_non_404_http_errors_by_default(self): response.raise_for_status.assert_called_once_with() + def test_get_pr_file_content_strict_missing_file_is_empty(self): + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.workspace_slug = "workspace" + provider.repo_slug = "repository" + provider.headers = {"Authorization": "Bearer token"} + provider.pr = MagicMock( + source_branch="feature", + destination_branch="main", + data={"destination": {"commit": {"hash": "base-sha"}}}, + ) + response = MagicMock(status_code=404, text="not found") + + with patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response): + assert provider.get_pr_file_content("CHANGELOG.md", "main", propagate_errors=True) == "" + + response.raise_for_status.assert_not_called() + @pytest.mark.parametrize("comment", [{"id": 123}, 123]) def test_remove_comment_accepts_returned_comment_or_stored_id(self, comment): provider = BitbucketProvider.__new__(BitbucketProvider) @@ -433,7 +611,7 @@ def test_persistent_review_migrates_legacy_heading_to_stable_identity(self): legacy.update.assert_called_once() updated_body = legacy.update.call_args.kwargs["content"]["raw"] assert updated_body.startswith("## Guideline Compliance Check 🔍\n\n") - assert PRReviewIdentity.REGULAR.value in updated_body + assert "[pr-agent:review:full]: https://github.com/The-PR-Agent/pr-agent" in updated_body provider.publish_comment.assert_not_called() def test_persistent_review_prefers_marked_comment_over_legacy_comment(self): @@ -479,7 +657,9 @@ def test_persistent_review_does_not_match_quoted_identity(self): unrelated.put.assert_not_called() provider.publish_comment.assert_called_once() - assert PRReviewIdentity.REGULAR.value in provider.publish_comment.call_args.args[0] + assert "[pr-agent:review:full]: https://github.com/The-PR-Agent/pr-agent" in ( + provider.publish_comment.call_args.args[0] + ) def test_nonreview_persistent_comment_requires_header_at_start(self): existing = MagicMock() @@ -555,6 +735,24 @@ def test_publish_code_suggestions_reports_success(self): for response in responses: response.raise_for_status.assert_called_once_with() + def test_publish_code_suggestions_preserves_backslashes_in_diff(self): + provider = self._provider_for_code_suggestions() + provider.publish_inline_comments = MagicMock(return_value=True) + suggestion = self._code_suggestion(2) + suggestion["body"] = '```suggestion\npattern = r"\\d+"\n```' + suggestion["original_suggestion"] = { + "existing_code": 'pattern = r"\\w+"', + "improved_code": 'pattern = r"\\d+"', + } + + result = provider.publish_code_suggestions([suggestion]) + + assert result is True + post_parameters = provider.publish_inline_comments.call_args.args[0] + assert len(post_parameters) == 1 + assert r"\w+" in post_parameters[0]["body"] + assert r"\d+" in post_parameters[0]["body"] + def test_publish_code_suggestions_reports_http_failures(self): provider = self._provider_for_code_suggestions() responses = [self._inline_comment_response(403), self._inline_comment_response(500)] @@ -636,6 +834,73 @@ def test_publish_code_suggestions_treats_unpublishable_list_as_successful_noop(s assert result is True request.assert_not_called() + def test_publish_code_suggestions_anchors_a_multi_line_suggestion_to_the_whole_range(self): + provider = self._provider_for_code_suggestions() + response = self._inline_comment_response(201) + + with patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response) as request: + result = provider.publish_code_suggestions([self._code_suggestion(10, 16)]) + + assert result is True + request.assert_called_once_with( + "POST", + provider.bitbucket_comment_api_url, + data='{"content": {"raw": "**Suggestion:** fix it"}, ' + '"inline": {"start_to": 10, "to": 16, "path": "src/example.py"}}', + headers=provider.headers, + ) + + def test_publish_code_suggestions_keeps_a_single_line_suggestion_on_one_line(self): + provider = self._provider_for_code_suggestions() + response = self._inline_comment_response(201) + + with patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response) as request: + result = provider.publish_code_suggestions([self._code_suggestion(2)]) + + assert result is True + request.assert_called_once_with( + "POST", + provider.bitbucket_comment_api_url, + data='{"content": {"raw": "**Suggestion:** fix it"}, "inline": {"to": 2, "path": "src/example.py"}}', + headers=provider.headers, + ) + + @pytest.mark.parametrize("end", [{"line": 7}, {"line": 6}, {"line": None}, {}], + ids=["end equal", "end below", "end null", "end absent"]) + def test_publish_inline_comments_stays_single_line_without_a_real_range(self, end): + # Only a genuine span may become 'start_to'; everything else stays a single-line comment. + provider = self._provider_for_code_suggestions() + response = self._inline_comment_response(201) + comment = {"body": "watch out", "path": "src/example.py", "start_line": 7, **end} + + with patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response) as request: + result = provider.publish_inline_comments([comment]) + + assert result is True + request.assert_called_once_with( + "POST", + provider.bitbucket_comment_api_url, + data='{"content": {"raw": "watch out"}, "inline": {"to": 7, "path": "src/example.py"}}', + headers=provider.headers, + ) + + def test_publish_inline_comments_truncates_a_multi_line_body(self): + provider = self._provider_for_code_suggestions() + provider.max_comment_length = 10 + response = self._inline_comment_response(201) + comment = {"body": "x" * 50, "path": "src/example.py", "start_line": 3, "line": 5} + + with patch("pr_agent.git_providers.bitbucket_provider.requests.request", return_value=response) as request: + result = provider.publish_inline_comments([comment]) + + assert result is True + request.assert_called_once_with( + "POST", + provider.bitbucket_comment_api_url, + data='{"content": {"raw": "xxxxxxx..."}, ' + '"inline": {"start_to": 3, "to": 5, "path": "src/example.py"}}', + headers=provider.headers, + ) def test_get_issue_comments_normalizes_cloud_comments(self): provider = BitbucketProvider.__new__(BitbucketProvider) @@ -651,6 +916,53 @@ def test_get_issue_comments_normalizes_cloud_comments(self): assert comments[0].body == "## PR Review\n\nreview body" assert comments[0]._cloud_comment is comment + def test_persistent_review_update_links_status_to_existing_cloud_comment(self): + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.pr = MagicMock() + provider.max_comment_length = 32_768 + provider.get_latest_commit_url = MagicMock(return_value="https://bitbucket.org/acme/repo/commits/head") + provider.publish_comment = MagicMock(return_value={"id": 8}) + + header = "## PR Review" + identity = PRReviewIdentity.REGULAR.value + comment_url = "https://bitbucket.org/acme/repo/pull-requests/1#comment-7" + existing = MagicMock() + existing.raw = f"{header}\n\n{identity}\n\nprevious review" + existing.data = {"links": {"html": {"href": comment_url}}} + provider.pr.comments.return_value = [existing] + + provider.publish_persistent_comment( + f"{header}\n\n{identity}\n\nupdated review", + initial_header=header, + final_update_message=True, + identity_marker=identity, + ) + + provider.publish_comment.assert_called_once_with( + f"**[Persistent review]({comment_url})** updated to latest commit " + "https://bitbucket.org/acme/repo/commits/head" + ) + + + def test_get_languages_returns_empty_map_when_repo_has_no_language(self): + # Bitbucket Cloud reports "language": null for repos it cannot classify; + # get_languages() must not emit a {None: 0} key or sort_files_by_main_languages() + # crashes on None.lower() (issue #3340). + repo = MagicMock() + repo.get_data.side_effect = lambda key: None + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.repo = repo + + assert provider.get_languages() == {} + + def test_get_languages_includes_detected_language(self): + repo = MagicMock() + repo.get_data.side_effect = lambda key: "Python" + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.repo = repo + + assert provider.get_languages() == {"Python": 0} + class TestBitbucketServerProvider: @staticmethod def _code_suggestion(line: int): @@ -1027,6 +1339,24 @@ def test_publish_code_suggestions_reports_success(self): assert result is True assert provider.bitbucket_client.post.call_count == 2 + def test_publish_code_suggestions_preserves_backslashes_in_diff(self): + provider = self._provider_for_code_suggestions() + provider.publish_inline_comments = MagicMock(return_value=True) + suggestion = self._code_suggestion(2) + suggestion["body"] = '```suggestion\npattern = r"\\d+"\n```' + suggestion["original_suggestion"] = { + "existing_code": 'pattern = r"\\w+"', + "improved_code": 'pattern = r"\\d+"', + } + + result = provider.publish_code_suggestions([suggestion]) + + assert result is True + post_parameters = provider.publish_inline_comments.call_args.args[0] + assert len(post_parameters) == 1 + assert r"\w+" in post_parameters[0]["body"] + assert r"\d+" in post_parameters[0]["body"] + def test_publish_code_suggestions_reports_failures_and_continues(self): provider = self._provider_for_code_suggestions() provider.bitbucket_client.post.side_effect = [ @@ -1089,6 +1419,24 @@ def test_get_repo_file_content_reads_from_target_ref(self): assert content == "repo context" provider.get_file.assert_called_once_with("AGENTS.md", "base-sha") + def test_remove_comment_deletes_comment_with_id_and_version(self): + provider = self._persistent_provider() + comment = SimpleNamespace(id=7, version=3) + + assert provider.remove_comment(comment) is True + + provider.bitbucket_client.delete_pull_request_comment.assert_called_once_with( + "AAA", "my-repo", 1, 7, 3 + ) + + def test_remove_comment_rejects_missing_version(self): + provider = self._persistent_provider() + comment = {"id": 7} + + assert provider.remove_comment(comment) is False + + provider.bitbucket_client.delete_pull_request_comment.assert_not_called() + def test_get_repo_file_content_from_default_branch(self): provider = BitbucketServerProvider.__new__(BitbucketServerProvider) provider.workspace_slug = "AAA" @@ -1130,6 +1478,24 @@ def test_get_repo_file_content_propagates_non_404_errors(self): with pytest.raises(HTTPError, match="500 Internal Server Error"): provider.get_repo_file_content("AGENTS.md") + def test_get_languages_returns_language_names(self): + # get_languages() must key on language NAMES (e.g. "Python"), not raw + # extensions ("py"): sort_files_by_main_languages() maps names back to + # extensions, so extension keys would drop every file into "Other". + provider = BitbucketServerProvider.__new__(BitbucketServerProvider) + provider.get_files = MagicMock(return_value=["a.py", "b.py", "c.py", "d.js", "weird.zzz"]) + + languages = provider.get_languages() + + # 3 Python + 1 JavaScript known; .zzz is unknown and excluded from the total. + assert languages == {"Python": 75.0, "JavaScript": 25.0} + + def test_get_languages_returns_empty_map_when_nothing_matches(self): + provider = BitbucketServerProvider.__new__(BitbucketServerProvider) + provider.get_files = MagicMock(return_value=["weird.zzz", ""]) + + assert provider.get_languages() == {} + def _make_provider_for_repo_settings(self, get_content_side_effect): # Bypass __init__ (which performs live API calls) and only wire up the # attributes get_repo_settings() relies on. @@ -1437,7 +1803,10 @@ def test_get_diff_files_multi_merge_diverge_60(self): FilePatchInfo( 'file\nwith\nmultiple\nlines\nto\nemulate\na\nreal\nfile', 'readme\nwithout\nsome\nlines\nto\nsimulate\na\nreal\nfile', - '@@ -1,9 +1,9 @@\n-file\n-with\n-multiple\n+readme\n+without\n+some\n lines\n to\n-emulate\n+simulate\n a\n real\n file\n', + ( + "@@ -1,9 +1,9 @@\n-file\n-with\n-multiple\n+readme\n+without\n" + "+some\n lines\n to\n-emulate\n+simulate\n a\n real\n file\n" + ), 'Readme.md', edit_type=EDIT_TYPE.MODIFIED, ) @@ -1459,7 +1828,10 @@ def test_get_diff_files_multi_merge_diverge_70(self): FilePatchInfo( 'file\nwith\nsome\nlines\nto\nemulate\na\nreal\nfile', 'readme\nwithout\nsome\nlines\nto\nsimulate\na\nreal\nfile', - '@@ -1,9 +1,9 @@\n-file\n-with\n+readme\n+without\n some\n lines\n to\n-emulate\n+simulate\n a\n real\n file\n', + ( + "@@ -1,9 +1,9 @@\n-file\n-with\n+readme\n+without\n some\n lines\n" + " to\n-emulate\n+simulate\n a\n real\n file\n" + ), 'Readme.md', edit_type=EDIT_TYPE.MODIFIED, ) @@ -1481,7 +1853,10 @@ def test_get_diff_files_multi_merge_diverge_816(self): FilePatchInfo( 'file\nwith\nsome\nlines\nto\nemulate\na\nreal\nfile', 'readme\nwithout\nsome\nlines\nto\nsimulate\na\nreal\nfile', - '@@ -1,9 +1,9 @@\n-file\n-with\n+readme\n+without\n some\n lines\n to\n-emulate\n+simulate\n a\n real\n file\n', + ( + "@@ -1,9 +1,9 @@\n-file\n-with\n+readme\n+without\n some\n lines\n" + " to\n-emulate\n+simulate\n a\n real\n file\n" + ), 'Readme.md', edit_type=EDIT_TYPE.MODIFIED, ) diff --git a/tests/unittest/test_bitbucket_server_clone_credentials.py b/tests/unittest/test_bitbucket_server_clone_credentials.py new file mode 100644 index 0000000000..ebd924c38d --- /dev/null +++ b/tests/unittest/test_bitbucket_server_clone_credentials.py @@ -0,0 +1,22 @@ +from unittest.mock import patch + +from pr_agent.git_providers.bitbucket_server_provider import BitbucketServerProvider + + +def test_clone_keeps_bearer_token_out_of_process_arguments(tmp_path): + provider = BitbucketServerProvider.__new__(BitbucketServerProvider) + provider.bearer_token = "secret bearer token" + captured = {} + + def fake_run(args, env, **kwargs): + captured["args"] = args + captured["env"] = env + + with patch("pr_agent.git_providers.bitbucket_server_provider.get_git_ssl_env", return_value={}), \ + patch("pr_agent.git_providers.bitbucket_server_provider.subprocess.run", side_effect=fake_run): + provider._clone_inner("https://bitbucket.example/scm/team/repo.git", str(tmp_path / "repo")) + + assert "secret bearer token" not in " ".join(captured["args"]) + assert captured["env"]["GIT_CONFIG_COUNT"] == "1" + assert captured["env"]["GIT_CONFIG_KEY_0"] == "http.extraHeader" + assert captured["env"]["GIT_CONFIG_VALUE_0"] == "Authorization: Bearer secret bearer token" diff --git a/tests/unittest/test_certifi_pin_policy.py b/tests/unittest/test_certifi_pin_policy.py new file mode 100644 index 0000000000..2e12c88e0c --- /dev/null +++ b/tests/unittest/test_certifi_pin_policy.py @@ -0,0 +1,34 @@ +"""Guard for the security-pin policy declared above ``dependencies`` in pyproject.toml. + +certifi is the CA trust store every outbound HTTPS call in pr-agent ultimately +validates against. An ``==`` pin freezes that bundle for pip consumers, who then +cannot pick up a rebuilt bundle -- one that drops a distrusted root, say -- without +overriding our requirement. The policy comment in pyproject.toml says +security-sensitive packages declare ranges for exactly this reason (#2523, #3194); +this test is what keeps the comment honest, since nothing else checks it. + +This asserts the declared requirement only. Exact versions for this repo's own +builds still come from uv.lock, so reproducibility is unaffected. +""" +import tomllib + +CERTIFI_POLICY = ( + "certifi must declare a floor (>=), not an == pin: it is the CA trust store, and an exact " + "pin leaves pip consumers stuck on a stale certificate bundle. Exact versions for this " + "repo's own builds come from uv.lock. See the comment above [project].dependencies." +) + + +def _certifi_requirement(pytestconfig): + with open(pytestconfig.inipath, "rb") as f: + dependencies = tomllib.load(f)["project"]["dependencies"] + + matches = [dep for dep in dependencies if dep.lower().startswith("certifi")] + assert len(matches) == 1, f"expected exactly one certifi requirement, found {matches!r}" + return matches[0] + + +def test_certifi_is_not_exact_pinned(pytestconfig): + requirement = _certifi_requirement(pytestconfig) + assert "==" not in requirement, f"{requirement!r}: {CERTIFI_POLICY}" + assert ">=" in requirement, f"{requirement!r}: {CERTIFI_POLICY}" diff --git a/tests/unittest/test_changelog_code_fence_stripping.py b/tests/unittest/test_changelog_code_fence_stripping.py index 89f47835b9..673e403588 100644 --- a/tests/unittest/test_changelog_code_fence_stripping.py +++ b/tests/unittest/test_changelog_code_fence_stripping.py @@ -86,7 +86,10 @@ def test_the_commit_hint_is_appended_when_not_committing(): @pytest.fixture def committing_tool(monkeypatch): """The real push path, with only the provider and the 5s settle sleep replaced.""" - monkeypatch.setattr("pr_agent.tools.pr_update_changelog.sleep", lambda seconds: None) + async def no_sleep(seconds): + assert seconds == 5 + + monkeypatch.setattr("pr_agent.tools.pr_update_changelog.asyncio.sleep", no_sleep) monkeypatch.setattr(get_settings().config, "git_provider", "local", raising=False) provider = MagicMock() provider.get_pr_branch.return_value = "feature/retry" @@ -97,12 +100,13 @@ def committing_tool(monkeypatch): return tool, provider -def test_the_committed_file_keeps_the_inline_code_span(committing_tool): +@pytest.mark.asyncio +async def test_the_committed_file_keeps_the_inline_code_span(committing_tool): tool, provider = committing_tool tool.prediction = "## 2026-09-06\n\n### Fixed\n- Handle `None` in `parse()`" new_file_content, answer = tool._prepare_changelog_update() - tool._push_changelog_update(new_file_content, answer) + await tool._push_changelog_update(new_file_content, answer) committed = provider.create_or_update_pr_file.call_args.kwargs["contents"] assert committed.startswith("## 2026-09-06\n\n### Fixed\n- Handle `None` in `parse()`") @@ -110,12 +114,13 @@ def test_the_committed_file_keeps_the_inline_code_span(committing_tool): assert committed.endswith(EXISTING) -def test_the_committed_file_is_written_to_the_pr_branch(committing_tool): +@pytest.mark.asyncio +async def test_the_committed_file_is_written_to_the_pr_branch(committing_tool): tool, provider = committing_tool tool.prediction = "## 2026-09-06\n- Handle `None`" new_file_content, answer = tool._prepare_changelog_update() - tool._push_changelog_update(new_file_content, answer) + await tool._push_changelog_update(new_file_content, answer) kwargs = provider.create_or_update_pr_file.call_args.kwargs assert kwargs["file_path"] == "CHANGELOG.md" @@ -123,13 +128,14 @@ def test_the_committed_file_is_written_to_the_pr_branch(committing_tool): assert kwargs["message"] == "[skip ci] Update CHANGELOG.md" -def test_a_fenced_answer_is_committed_without_its_fence(committing_tool): +@pytest.mark.asyncio +async def test_a_fenced_answer_is_committed_without_its_fence(committing_tool): """The model often wraps the whole entry; the fence must not reach the file.""" tool, provider = committing_tool tool.prediction = "```markdown\n## 2026-09-06\n- Handle `None`\n```" new_file_content, answer = tool._prepare_changelog_update() - tool._push_changelog_update(new_file_content, answer) + await tool._push_changelog_update(new_file_content, answer) committed = provider.create_or_update_pr_file.call_args.kwargs["contents"] assert committed.startswith("## 2026-09-06\n- Handle `None`") diff --git a/tests/unittest/test_ci_python_version_coverage.py b/tests/unittest/test_ci_python_version_coverage.py new file mode 100644 index 0000000000..ab7d6de2dd --- /dev/null +++ b/tests/unittest/test_ci_python_version_coverage.py @@ -0,0 +1,130 @@ +"""Guard: every interpreter pyproject singles out is exercised by build-and-test.yaml. + +`requires-python = ">=3.12"` on its own is open-ended, but the per-interpreter +dependency pins say which minors support is actually claimed for -- currently +google-cloud-storage, split at `python_version >= '3.13'` for #2480. CI used to +run only inside docker/Dockerfile's base image, so whichever minor that image +did not carry went declared and never tested (#3187). The base has since moved +to 3.14 (#3294), which is why both declared minors now need native jobs. + +Keep docker/Dockerfile.lambda on the same Python minor: #3294 left the +Lambda image behind on 3.12 while the main Dockerfile moved to 3.14. The sync +test below pins the two bases together so a one-file bump cannot slip +through silently again. + +These tests assert the repository's CI configuration, not pr_agent logic, hence a +file of their own. They read the checked-in workflow and Dockerfiles, which the +`test` docker target copies into the image alongside publish.yml so the suite +carries the same signal in CI as it does locally. No network, no docker. +""" +import re +import tomllib +from pathlib import Path + +import yaml + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +BUILD_AND_TEST_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "build-and-test.yaml" +PYPROJECT = REPOSITORY_ROOT / "pyproject.toml" +DOCKERFILE = REPOSITORY_ROOT / "docker" / "Dockerfile" +LAMBDA_DOCKERFILE = REPOSITORY_ROOT / "docker" / "Dockerfile.lambda" + +# "google-cloud-storage==3.12.0; python_version >= '3.13'" -> "3.13" +PIN_MARKER_VERSION = re.compile(r"""python_version\s*>=\s*['"](\d+\.\d+)""") +REQUIRES_PYTHON_MINIMUM = re.compile(r">=\s*(\d+\.\d+)") +# Only the base stage names an image; every other stage is "FROM base AS ...". +DOCKER_BASE_VERSION = re.compile(r"^FROM python:(\d+\.\d+)", re.MULTILINE) +LAMBDA_BASE_VERSION = re.compile(r"^FROM public\.ecr\.aws/lambda/python:(\d+\.\d+)", re.MULTILINE) +LAMBDA_INTERPRETER_VERSION = re.compile( + r"^RUN uv sync\b.*--python /var/lang/bin/python(\d+\.\d+)(?:\s|$)", + re.MULTILINE, +) +LAMBDA_SITE_PACKAGES_VERSION = re.compile( + r'^ENV PYTHONPATH=["\']?/opt/venv/lib/python(\d+\.\d+)/site-packages', + re.MULTILINE, +) +UV_PYTHON_FLAG = re.compile(r"--python[ =](\d+\.\d+)") + + +def _declared_python_versions() -> set[str]: + """Minors pyproject claims support for: the floor, plus every pinned marker.""" + project = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["project"] + versions = set(PIN_MARKER_VERSION.findall(" ".join(project["dependencies"]))) + versions.add(REQUIRES_PYTHON_MINIMUM.search(project["requires-python"]).group(1)) + return versions + + +def _tested_python_versions() -> set[str]: + """Minors build-and-test.yaml runs the unit suite on. + + The Dockerfile base counts because the workflow builds its ``test`` target and + runs pytest inside it; native jobs pin their interpreter on the uv command line, + through setup-python, or over a matrix. Matrix legs are read from the strategy + rather than the step, because the step only carries the expression. + """ + versions = set(DOCKER_BASE_VERSION.findall(DOCKERFILE.read_text(encoding="utf-8"))) + workflow = yaml.safe_load(BUILD_AND_TEST_WORKFLOW.read_text(encoding="utf-8")) + for job in workflow["jobs"].values(): + matrix = (job.get("strategy") or {}).get("matrix") or {} + versions.update(str(leg) for leg in matrix.get("python-version", [])) + for step in job.get("steps", []): + versions.update(UV_PYTHON_FLAG.findall(step.get("run", ""))) + pinned = (step.get("with") or {}).get("python-version") + if pinned: + versions.add(str(pinned)) + return versions + + +def test_every_declared_python_version_is_tested_in_ci() -> None: + declared = _declared_python_versions() + tested = _tested_python_versions() + + # Without a conditional pin the declared set collapses to the floor alone, + # which the docker base always satisfies -- the assertion below would then + # pass vacuously however little CI actually ran. + assert len(declared) > 1, ( + f"Parsed no per-interpreter pin out of pyproject's dependencies, so this guard would " + f"pass vacuously. Either the pins are gone (and requires-python should be narrowed to " + f"match) or {PIN_MARKER_VERSION.pattern!r} has gone stale." + ) + assert declared <= tested, ( + f"pyproject declares support for Python {sorted(declared)}, but build-and-test.yaml only " + f"exercises {sorted(tested)}. Add a job for {sorted(declared - tested)}, or narrow " + f"requires-python and drop the now-unreachable dependency pins." + ) + + +def test_lambda_base_matches_main_base() -> None: + """docker/Dockerfile.lambda must track docker/Dockerfile's Python minor. + + #3294 moved the main image to 3.14 while the Lambda image stayed on + 3.12, and nothing failed because no test read the Lambda Dockerfile. + Only the minor is asserted: AWS publishes no CPython patch tags for + Lambda bases, so the Lambda Dockerfile intentionally floats on the + minor tag instead of chasing per-build dated tags. The uv interpreter + path and site-packages PYTHONPATH embed the same minor and are + asserted too, because nothing builds this image in CI before publish. + """ + main = DOCKER_BASE_VERSION.search(DOCKERFILE.read_text(encoding="utf-8")) + lambda_dockerfile = LAMBDA_DOCKERFILE.read_text(encoding="utf-8") + lambda_base = LAMBDA_BASE_VERSION.search(lambda_dockerfile) + interpreter = LAMBDA_INTERPRETER_VERSION.search(lambda_dockerfile) + site_packages = LAMBDA_SITE_PACKAGES_VERSION.search(lambda_dockerfile) + assert main is not None, f"could not parse a base Python version out of {DOCKERFILE}" + assert lambda_base is not None, f"could not parse a base Python version out of {LAMBDA_DOCKERFILE}" + assert lambda_base.group(1) == main.group(1), ( + f"docker/Dockerfile is on Python {main.group(1)} but docker/Dockerfile.lambda is on " + f"Python {lambda_base.group(1)}. Bump the Lambda base tag, interpreter path, and " + f"site-packages PYTHONPATH together." + ) + assert interpreter is not None, ( + f"could not parse the uv --python interpreter version out of {LAMBDA_DOCKERFILE}" + ) + assert site_packages is not None, ( + f"could not parse the site-packages PYTHONPATH version out of {LAMBDA_DOCKERFILE}" + ) + assert interpreter.group(1) == lambda_base.group(1) == site_packages.group(1), ( + f"docker/Dockerfile.lambda mixes Python versions: base image {lambda_base.group(1)}, " + f"interpreter {interpreter.group(1)}, site-packages {site_packages.group(1)}. Bump the " + f"base tag, interpreter path, and site-packages PYTHONPATH together." + ) diff --git a/tests/unittest/test_cli_args_security.py b/tests/unittest/test_cli_args_security.py index 1f775d4bd9..5143092425 100644 --- a/tests/unittest/test_cli_args_security.py +++ b/tests/unittest/test_cli_args_security.py @@ -68,6 +68,27 @@ "--pr_reviewer.publish_error_details=true", "--pr_reviewer__publish_error_details=true", '--pr_reviewer={"publish_error_details": true}', + # repo_context_max_sibling_files is host-only: letting a comment raise it would defeat the + # sibling-fetch safety bound and allow unbounded cross-repository API calls. + "--config.repo_context_max_sibling_files=1000", + "--config__repo_context_max_sibling_files=1000", + '--config={"repo_context_max_sibling_files": 1000}', + # repo_context_files selects which files become model instructions, so comment arguments + # must not be able to point the bot at arbitrary sibling repo content. + '--config.repo_context_files=[{"repo_id": "group/A/idea", "file_path": "AGENTS.md"}]', + "--config__repo_context_files=[\"AGENTS.md\"]", + '--config={"repo_context_files": ["AGENTS.md"]}', + # repo_context_sibling_repos is the host-only allowlist of sibling repositories whose + # files may be selected; neither repo settings nor comment arguments can change it. + "--config.repo_context_sibling_repos=[]", + "--config__repo_context_sibling_repos=[]", + '--config={"repo_context_sibling_repos": []}', + # description_issue_regex is compiled and run with finditer over the whole pull-request + # description. An ambiguous pattern backtracks exponentially, so a commenter who can choose + # it can burn a worker on a short body; it stays an operator choice. + "--config.description_issue_regex=(?:[A-Za-z ]+)+X(d+)", + "--config__description_issue_regex=(?:[A-Za-z ]+)+X(d+)", + '--config={"description_issue_regex": "(?:[A-Za-z ]+)+X(d+)"}', ] diff --git a/tests/unittest/test_cli_artifact_context.py b/tests/unittest/test_cli_artifact_context.py index 982807a076..50d6322bb0 100644 --- a/tests/unittest/test_cli_artifact_context.py +++ b/tests/unittest/test_cli_artifact_context.py @@ -7,7 +7,7 @@ def test_run_injects_the_artifact_context_before_handling_the_request(): """A pipeline that runs the CLI gets the same [artifacts] injection as the GitHub Action.""" order = [] - fake_settings = SimpleNamespace(litellm={}, set=MagicMock()) + fake_settings = SimpleNamespace(config={}, litellm={}, set=MagicMock()) async def fake_handle_request(*_args, **_kwargs): order.append("handle_request") diff --git a/tests/unittest/test_cli_command_tokenization.py b/tests/unittest/test_cli_command_tokenization.py new file mode 100644 index 0000000000..aeee5c9504 --- /dev/null +++ b/tests/unittest/test_cli_command_tokenization.py @@ -0,0 +1,45 @@ +from unittest.mock import Mock + +import pytest + +from pr_agent import cli + + +@pytest.mark.parametrize( + ("command", "expected_command", "expected_args"), + [ + ( + "/review --pr_reviewer.extra_instructions='be concise please'", + "review", + ['--pr_reviewer.extra_instructions="be concise please"'], + ), + ( + '/review --pr_reviewer.extra_instructions="true"', + "review", + ['--pr_reviewer.extra_instructions="true"'], + ), + ('/ask "What changed here?"', "ask", ["What changed here?"]), + ], +) +def test_run_command_preserves_quoted_arguments(monkeypatch, command, expected_command, expected_args): + run = Mock(return_value=0) + monkeypatch.setattr(cli, "run", run) + pr_url = "https://example.com/org/repo/pull/1?label=needs%20review&sort=asc" + + assert cli.run_command(pr_url, command) == 0 + + run.assert_called_once() + args = run.call_args.kwargs["args"] + assert args.pr_url == pr_url + assert args.command == expected_command + assert args.rest == expected_args + + +def test_run_command_rejects_unclosed_quote_before_dispatch(monkeypatch): + run = Mock() + monkeypatch.setattr(cli, "run", run) + + with pytest.raises(ValueError, match="No closing quotation"): + cli.run_command("https://example.com/org/repo/pull/1", '/ask "unfinished') + + run.assert_not_called() diff --git a/tests/unittest/test_cli_config_branch.py b/tests/unittest/test_cli_config_branch.py index c3f0e09b69..52e02d658f 100644 --- a/tests/unittest/test_cli_config_branch.py +++ b/tests/unittest/test_cli_config_branch.py @@ -5,12 +5,15 @@ def test_set_parser_supports_config_branch_flag(): - args = cli.set_parser().parse_args(["--pr_url=https://github.com/a/b/pull/1", "--config-branch", "feature", "review"]) + args = cli.set_parser().parse_args( + ["--pr_url=https://github.com/a/b/pull/1", "--config-branch", "feature", "review"] + ) assert args.config_branch == "feature" def test_run_sets_config_branch_from_cli_flag(): fake_settings = SimpleNamespace( + config={}, litellm={}, set=MagicMock(), ) @@ -29,6 +32,7 @@ async def fake_handle_request(*_args, **_kwargs): def test_run_sets_config_branch_from_env_var(): fake_settings = SimpleNamespace( + config={}, litellm={}, set=MagicMock(), ) @@ -51,6 +55,7 @@ async def fake_handle_request(*_args, **_kwargs): def test_run_whitespace_cli_branch_falls_back_to_env_var(): """A whitespace-only --config-branch must not short-circuit the env fallback.""" fake_settings = SimpleNamespace( + config={}, litellm={}, set=MagicMock(), ) @@ -74,6 +79,7 @@ def test_run_reconciles_config_branch_when_absent(): """Without a flag or env var, CONFIG.CONFIG_BRANCH must be reset (not leaked from a previous run() call in the same process-wide settings singleton).""" fake_settings = SimpleNamespace( + config={}, litellm={}, set=MagicMock(), ) diff --git a/tests/unittest/test_cli_exit_status.py b/tests/unittest/test_cli_exit_status.py new file mode 100644 index 0000000000..199e0e762e --- /dev/null +++ b/tests/unittest/test_cli_exit_status.py @@ -0,0 +1,431 @@ +import os +import subprocess +import sys +import tomllib +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from pr_agent import cli +from pr_agent.config_loader import get_settings + +_CONSOLE_SCRIPT_FALLBACK = ( + "import sys; from pr_agent.cli import run; sys.exit(run())" +) +_SETTINGS_KEYS = [ + "config.propagate_tool_errors", + "config.cli_mode", + "config.config_branch", + "config.extra_config_url", +] + + +@pytest.fixture(autouse=True) +def restore_cli_settings(): + settings = get_settings() + original = {key: settings.get(key, None) for key in _SETTINGS_KEYS} + yield + for key, value in original.items(): + settings.set(key, value) + + +def _run_with_result(monkeypatch, result, *, propagate_tool_errors): + fake_settings = SimpleNamespace( + config={"propagate_tool_errors": propagate_tool_errors}, + litellm={}, + set=MagicMock(), + ) + + async def fake_handle_request(*_args, **_kwargs): + return result + + monkeypatch.setattr(cli, "get_settings", lambda: fake_settings) + monkeypatch.setattr( + cli, + "PRAgent", + lambda: SimpleNamespace(handle_request=fake_handle_request), + ) + monkeypatch.setattr(cli, "inject_artifact_context", lambda: None) + + return cli.run(inargs=["--pr_url=https://example.com/org/repo/pull/1", "review"]) + + +@pytest.mark.parametrize( + ("request_result", "propagate_tool_errors", "expected_status", "prints_help"), + [ + (True, False, None, False), + (True, True, None, False), + (False, False, None, True), + (False, True, 1, True), + (None, True, None, True), + (0, True, None, True), + ], +) +def test_run_maps_request_result_to_status( + monkeypatch, + capsys, + request_result, + propagate_tool_errors, + expected_status, + prints_help, +): + status = _run_with_result( + monkeypatch, + request_result, + propagate_tool_errors=propagate_tool_errors, + ) + + assert status == expected_status + assert ("usage:" in capsys.readouterr().out) is prints_help + + +def test_run_reads_effective_setting_after_dispatch(monkeypatch): + fake_settings = SimpleNamespace( + config={"propagate_tool_errors": False}, + litellm={}, + set=MagicMock(), + ) + + async def fake_handle_request(*_args, **_kwargs): + fake_settings.config["propagate_tool_errors"] = True + return False + + monkeypatch.setattr(cli, "get_settings", lambda: fake_settings) + monkeypatch.setattr( + cli, + "PRAgent", + lambda: SimpleNamespace(handle_request=fake_handle_request), + ) + monkeypatch.setattr(cli, "inject_artifact_context", lambda: None) + + assert cli.run(inargs=["--pr_url=https://example.com/org/repo/pull/1", "review"]) == 1 + + +def test_run_drains_callbacks_before_returning_failure_status(monkeypatch): + events = [] + fake_settings = SimpleNamespace( + config={"propagate_tool_errors": True}, + litellm={}, + set=MagicMock(), + ) + + async def fake_handle_request(*_args, **_kwargs): + events.append("request") + return False + + async def fake_drain(*_args, **_kwargs): + events.append("drain") + + monkeypatch.setattr(cli, "get_settings", lambda: fake_settings) + monkeypatch.setattr( + cli, + "PRAgent", + lambda: SimpleNamespace(handle_request=fake_handle_request), + ) + monkeypatch.setattr(cli, "inject_artifact_context", lambda: None) + monkeypatch.setattr(cli, "litellm_callbacks_registered", lambda: True) + monkeypatch.setattr(cli, "drain_litellm_callbacks", fake_drain) + + status = cli.run(inargs=["--pr_url=https://example.com/org/repo/pull/1", "review"]) + + events.append(f"status:{status}") + assert events == ["request", "drain", "status:1"] + + +@pytest.mark.parametrize( + ("repo_value", "cli_value", "expected_status"), + [ + (False, True, 1), + (True, None, 1), + (True, False, None), + ], +) +def test_real_request_failure_uses_effective_propagation_setting( + monkeypatch, + repo_value, + cli_value, + expected_status, +): + from pr_agent.agent import pr_agent as pr_agent_module + + events = [] + expected_effective_value = repo_value if cli_value is None else cli_value + + class FailingReview: + def __init__(self, *_args, **_kwargs): + pass + + async def run(self): + events.append("tool") + assert ( + get_settings().config.get("propagate_tool_errors") + is expected_effective_value + ) + raise RuntimeError("controlled tool failure") + + async def fake_drain(*_args, **_kwargs): + events.append("callbacks") + + def fake_apply_repo_settings(*_args, **_kwargs): + get_settings().set("CONFIG.PROPAGATE_TOOL_ERRORS", repo_value) + + monkeypatch.setitem(pr_agent_module.command2class, "review", FailingReview) + monkeypatch.setattr(pr_agent_module, "apply_repo_settings", fake_apply_repo_settings) + monkeypatch.setattr(pr_agent_module, "flush_telemetry", lambda: events.append("telemetry")) + monkeypatch.setattr(cli, "inject_artifact_context", lambda: None) + monkeypatch.setattr(cli, "litellm_callbacks_registered", lambda: True) + monkeypatch.setattr(cli, "drain_litellm_callbacks", fake_drain) + + inargs = [ + "--pr_url=https://example.com/org/repo/pull/1", + "review", + ] + if cli_value is not None: + inargs.append(f"--config.propagate_tool_errors={str(cli_value).lower()}") + + status = cli.run(inargs=inargs) + + events.append(f"status:{status}") + assert events == [ + "tool", + "telemetry", + "callbacks", + f"status:{expected_status}", + ] + + +@pytest.mark.parametrize( + ("baseline", "override_source", "override", "first_succeeds", "expected_statuses"), + [ + (False, "repository", True, False, [1, None]), + (False, "command", True, False, [1, None]), + (True, "repository", False, False, [None, 1]), + (True, "command", False, False, [None, 1]), + (False, "repository", True, True, [None, None]), + (False, "command", True, True, [None, None]), + (True, "repository", False, True, [None, 1]), + (True, "command", False, True, [None, 1]), + ], +) +def test_run_restores_propagation_setting_between_invocations( + monkeypatch, + baseline, + override_source, + override, + first_succeeds, + expected_statuses, +): + from pr_agent.agent import pr_agent as pr_agent_module + + settings = get_settings() + settings.set("CONFIG.PROPAGATE_TOOL_ERRORS", baseline) + apply_calls = 0 + observed_values = [] + request_results = iter([first_succeeds, False]) + + class ControlledReview: + def __init__(self, *_args, **_kwargs): + pass + + async def run(self): + observed_values.append(settings.config.get("propagate_tool_errors")) + if next(request_results): + return + raise RuntimeError("controlled tool failure") + + def fake_apply_repo_settings(*_args, **_kwargs): + nonlocal apply_calls + if override_source == "repository" and apply_calls == 0: + settings.set("CONFIG.PROPAGATE_TOOL_ERRORS", override) + apply_calls += 1 + + monkeypatch.setitem(pr_agent_module.command2class, "review", ControlledReview) + monkeypatch.setattr(pr_agent_module, "apply_repo_settings", fake_apply_repo_settings) + monkeypatch.setattr(pr_agent_module, "flush_telemetry", lambda: None) + monkeypatch.setattr(cli, "inject_artifact_context", lambda: None) + monkeypatch.setattr(cli, "litellm_callbacks_registered", lambda: False) + + first_inargs = ["--pr_url=https://example.com/org/repo/pull/1", "review"] + if override_source == "command": + first_inargs.append(f"--config.propagate_tool_errors={str(override).lower()}") + + statuses = [ + cli.run(inargs=first_inargs), + cli.run(inargs=["--pr_url=https://example.com/org/repo/pull/2", "review"]), + ] + + assert observed_values == [override, baseline] + assert statuses == expected_statuses + assert settings.config.get("propagate_tool_errors") is baseline + + +def test_run_restores_propagation_setting_when_callback_drain_raises(monkeypatch): + settings = get_settings() + settings.set("CONFIG.PROPAGATE_TOOL_ERRORS", False) + + async def fake_handle_request(*_args, **_kwargs): + settings.set("CONFIG.PROPAGATE_TOOL_ERRORS", True) + return False + + async def failing_drain(*_args, **_kwargs): + raise RuntimeError("controlled callback failure") + + monkeypatch.setattr( + cli, + "PRAgent", + lambda: SimpleNamespace(handle_request=fake_handle_request), + ) + monkeypatch.setattr(cli, "inject_artifact_context", lambda: None) + monkeypatch.setattr(cli, "litellm_callbacks_registered", lambda: True) + monkeypatch.setattr(cli, "drain_litellm_callbacks", failing_drain) + + with pytest.raises(RuntimeError, match="controlled callback failure"): + cli.run(inargs=["--pr_url=https://example.com/org/repo/pull/1", "review"]) + + assert settings.config.get("propagate_tool_errors") is False + + +def _console_script_entrypoint(python_executable): + console_script = Path(python_executable).with_name("pr-agent") + if sys.platform == "win32": + console_script = console_script.with_suffix(".exe") + if console_script.is_file(): + return [str(console_script)] + return [python_executable, "-c", _CONSOLE_SCRIPT_FALLBACK] + + +def test_console_script_maps_to_cli_run(): + with (Path(__file__).parents[2] / "pyproject.toml").open("rb") as pyproject: + project = tomllib.load(pyproject)["project"] + + assert project["scripts"]["pr-agent"] == "pr_agent.cli:run" + + +def test_console_script_falls_back_in_source_only_environment(tmp_path): + python_executable = str(tmp_path / "bin" / "python") + assert _console_script_entrypoint(python_executable) == [ + python_executable, + "-c", + _CONSOLE_SCRIPT_FALLBACK, + ] + + +def test_run_command_forwards_run_status(monkeypatch): + def fake_run(*, args): + assert args.pr_url == "https://example.com/org/repo/pull/1" + assert args.command == "review" + return 1 + + monkeypatch.setattr(cli, "run", fake_run) + + assert cli.run_command("https://example.com/org/repo/pull/1", "/review") == 1 + + +@pytest.fixture +def process_entrypoints(): + return ( + _console_script_entrypoint(sys.executable), + [sys.executable, "-m", "pr_agent.cli"], + [sys.executable, "-m", "pr_agent.cli_pip"], + [sys.executable, "pr_agent/cli_pip.py"], + ) + + +@pytest.fixture +def argparse_entrypoints(process_entrypoints): + return process_entrypoints[:2] + + +@pytest.fixture +def controlled_process_env(tmp_path): + sitecustomize = tmp_path / "sitecustomize.py" + sitecustomize.write_text( + """ +import os + +from pr_agent.agent.pr_agent import PRAgent +from pr_agent.config_loader import get_settings + + +async def controlled_handle_request(self, pr_url, request, notify=None): + get_settings().set( + \"CONFIG.PROPAGATE_TOOL_ERRORS\", + os.environ.get(\"PR_AGENT_TEST_PROPAGATE\") == \"true\", + ) + result = os.environ.get(\"PR_AGENT_TEST_RESULT\") + if result == \"none\": + return None + if result == \"zero\": + return 0 + return result == \"true\" + + +PRAgent.handle_request = controlled_handle_request +""".lstrip(), + encoding="utf-8", + ) + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join( + part for part in (str(tmp_path), env.get("PYTHONPATH")) if part + ) + return env + + +@pytest.mark.parametrize( + ("request_result", "propagate_tool_errors", "expected_status"), + [ + ("false", "true", 1), + ("false", "false", 0), + ], +) +def test_process_entrypoints_map_request_status( + process_entrypoints, + controlled_process_env, + request_result, + propagate_tool_errors, + expected_status, +): + env = controlled_process_env.copy() + env["PR_AGENT_TEST_RESULT"] = request_result + env["PR_AGENT_TEST_PROPAGATE"] = propagate_tool_errors + + for entrypoint in process_entrypoints: + completed = subprocess.run( + [*entrypoint, "--pr_url=https://example.com/org/repo/pull/1", "review"], + cwd=Path(__file__).parents[2], + env=env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert completed.returncode == expected_status, ( + entrypoint, + completed.stdout, + completed.stderr, + ) + + +def test_process_entrypoints_preserve_argparse_status( + argparse_entrypoints, + controlled_process_env, +): + for entrypoint in argparse_entrypoints: + completed = subprocess.run( + [*entrypoint, "not-a-command"], + cwd=Path(__file__).parents[2], + env=controlled_process_env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert completed.returncode == 2, ( + entrypoint, + completed.stdout, + completed.stderr, + ) diff --git a/tests/unittest/test_cli_plain_diff_mode.py b/tests/unittest/test_cli_plain_diff_mode.py index 2011d1f1d8..b3a9a057e4 100644 --- a/tests/unittest/test_cli_plain_diff_mode.py +++ b/tests/unittest/test_cli_plain_diff_mode.py @@ -1,13 +1,119 @@ +import builtins +import io + import pytest -from pr_agent.cli import run, set_parser +from pr_agent.cli import _resolve_output_option, commands, run, set_parser from pr_agent.config_loader import get_settings # Keys run() mutates on the process-wide settings singleton, directly or via the # diff-mode CLI path. Snapshotted and restored around every test (autouse) so # state never leaks, even when run() sets keys the test never touches itself. -_SETTINGS_KEYS = ["plain_diff.content", "plain_diff.output_path", "plain_diff.json_output_path", - "config.git_provider", "config.publish_output"] +_SETTINGS_KEYS = [ + "plain_diff.content", + "plain_diff.output_path", + "plain_diff.json_output_path", + "config.git_provider", + "config.publish_output", + "config.cli_mode", + "config.config_branch", + "config.extra_config_url", +] + +_DIFF = ( + "diff --git a/foo.py b/foo.py\n" + "index 1111111..2222222 100644\n" + "--- a/foo.py\n" + "+++ b/foo.py\n" + "@@ -1,3 +1,3 @@\n" + " line1\n-line2\n+line2-changed\n line3\n" +) +_REVIEW_MARKER = "plain-diff auto-review output" +_CANNED_REVIEW = f"""\ +review: + estimated_effort_to_review_[1-5]: '1' + score: '90' + relevant_tests: 'No' + key_issues_to_review: + - relevant_file: foo.py + issue_header: '{_REVIEW_MARKER}' + issue_content: 'Verify the changed line.' + start_line: 2 + end_line: 2 + security_concerns: 'No' +""" +_OUTPUT_OPTION_PREFIXES = [ + (option[:length], option) + for option in ("--output", "--json-output") + for length in range(3, len(option) + 1) +] + +_MARKDOWN_COMMANDS = [ + "review", + "review_pr", + "auto_review", + "describe", + "describe_pr", + "improve", + "improve_code", + "ask", + "ask_question", + "config", + "settings", + "help", +] + +_NON_MARKDOWN_COMMANDS = [ + "answer", + "ask_line", + "update_changelog", + "similar_issue", + "add_docs", + "generate_labels", +] + + +def test_output_command_matrix_covers_cli_commands(): + assert set(_MARKDOWN_COMMANDS) | set(_NON_MARKDOWN_COMMANDS) == set(commands) + assert set(_MARKDOWN_COMMANDS).isdisjoint(_NON_MARKDOWN_COMMANDS) + + +def _rejecting_input_args(input_mode, monkeypatch): + class UnreadableStdin: + def read(self): + pytest.fail("stdin must not be read for invalid output options") + + if input_mode == "stdin": + monkeypatch.setattr("sys.stdin", UnreadableStdin()) + return ["--stdin"] + + original_open = builtins.open + + def guarded_open(path, *args, **kwargs): + if path == "changes.diff": + pytest.fail("the diff file must not be opened for invalid output options") + return original_open(path, *args, **kwargs) + + monkeypatch.setattr("builtins.open", guarded_open) + return ["--diff-file", "changes.diff"] + + +def _valid_input_args(input_mode, monkeypatch, tmp_path): + if input_mode == "stdin": + monkeypatch.setattr("sys.stdin", io.StringIO(_DIFF)) + return ["--stdin"] + + diff_file = tmp_path / "changes.diff" + diff_file.write_text(_DIFF, encoding="utf-8") + return ["--diff-file", str(diff_file)] + + +def _fail_if_agent_constructed(monkeypatch): + class NeverAgent: + def __init__(self): + pytest.fail("PRAgent must not be constructed for invalid output options") + + monkeypatch.setattr("pr_agent.cli.PRAgent", NeverAgent) @pytest.fixture(autouse=True) @@ -43,6 +149,28 @@ def test_parser_stdin_flag(): assert args.stdin is True +@pytest.mark.parametrize( + ("option_spelling", "option"), + _OUTPUT_OPTION_PREFIXES, +) +def test_parser_accepts_unambiguous_output_abbreviations_before_command( + option_spelling, option, +): + parser = set_parser() + args = parser.parse_args(["--stdin", option_spelling, "result", "review"]) + + destination = option[2:].replace("-", "_") + assert getattr(args, destination) == "result" + + +def test_misplaced_output_abbreviation_must_be_unambiguous(): + parser = set_parser() + parser.add_argument("--outcome") + + assert _resolve_output_option(parser, "--out=value") is None + assert _resolve_output_option(parser, "--output=value") == "--output" + + def test_missing_diff_file_fails_fast(tmp_path, capsys): """A non-existent --diff-file must exit cleanly via parser.error (SystemExit) with a clear message, not crash with an uncaught OSError traceback.""" @@ -56,27 +184,232 @@ def test_missing_diff_file_fails_fast(tmp_path, capsys): def test_json_output_outside_diff_mode_fails_fast(capsys): """Reject --json-output in hosted-provider mode via parser.error instead of silently dropping the explicitly requested artifact.""" - with pytest.raises(SystemExit): + with pytest.raises(SystemExit) as exc_info: run(inargs=["--pr_url", "https://example/pr/1", "--json-output", "out.json", "review"]) + assert exc_info.value.code == 2 err = capsys.readouterr().err assert "--json-output is only supported in plain-diff mode" in err -_DIFF = ( - "diff --git a/foo.py b/foo.py\n" - "index 1111111..2222222 100644\n" - "--- a/foo.py\n" - "+++ b/foo.py\n" - "@@ -1,3 +1,3 @@\n" - " line1\n-line2\n+line2-changed\n line3\n" +@pytest.mark.parametrize( + "target_args", + [ + ["--pr_url", "https://example/pr/1"], + ["--issue_url", "https://example/issue/1"], + [], + ], ) +def test_markdown_output_outside_plain_diff_fails_before_dispatch(target_args, monkeypatch, capsys): + _fail_if_agent_constructed(monkeypatch) + + with pytest.raises(SystemExit) as exc_info: + run(inargs=[*target_args, "--output", "out.md", "review"]) + + assert exc_info.value.code == 2 + assert "--output is only supported in plain-diff mode" in capsys.readouterr().err + + +def test_markdown_output_rejects_local_git_mode_before_dispatch(monkeypatch, capsys): + _fail_if_agent_constructed(monkeypatch) + + with pytest.raises(SystemExit) as exc_info: + run(inargs=[ + "--pr_url", "main", + "--output", "out.md", + "review", + "--config.git_provider=local", + ]) + + assert exc_info.value.code == 2 + assert "--output is only supported in plain-diff mode" in capsys.readouterr().err + + +@pytest.mark.parametrize("option", ["--output", "--json-output"]) +@pytest.mark.parametrize("spelling", ["equals", "separate"]) +@pytest.mark.parametrize("mode", ["hosted", "stdin"]) +def test_empty_output_paths_fail_before_input_or_dispatch( + option, spelling, mode, monkeypatch, capsys, +): + _fail_if_agent_constructed(monkeypatch) + target_args = ["--pr_url", "https://example/pr/1"] + if mode == "stdin": + target_args = _rejecting_input_args("stdin", monkeypatch) + option_args = [f"{option}="] if spelling == "equals" else [option, ""] + + with pytest.raises(SystemExit) as exc_info: + run(inargs=[*target_args, *option_args, "review"]) + + assert exc_info.value.code == 2 + assert f"{option} requires a non-empty path" in capsys.readouterr().err + + +@pytest.mark.parametrize("command", _NON_MARKDOWN_COMMANDS) +@pytest.mark.parametrize("input_mode", ["stdin", "file"]) +def test_markdown_output_rejects_unsupported_plain_diff_commands_before_read( + command, input_mode, monkeypatch, capsys, +): + input_args = _rejecting_input_args(input_mode, monkeypatch) + + with pytest.raises(SystemExit) as exc_info: + run(inargs=[*input_args, "--output", "out.md", command]) + + assert exc_info.value.code == 2 + assert "--output is not supported for" in capsys.readouterr().err + + +@pytest.mark.parametrize("command", _MARKDOWN_COMMANDS) +@pytest.mark.parametrize("input_mode", ["stdin", "file"]) +def test_markdown_output_accepts_compatible_plain_diff_commands( + command, input_mode, monkeypatch, tmp_path, +): + captured = {} + + class FakeAgent: + async def handle_request(self, target, request, notify=None): + captured["target"] = target + captured["request"] = request + captured["output_path"] = get_settings().plain_diff.output_path + return True + + monkeypatch.setattr("pr_agent.cli.PRAgent", FakeAgent) + input_args = _valid_input_args(input_mode, monkeypatch, tmp_path) + + output = tmp_path / "result.md" + run(inargs=[*input_args, "--output", str(output), command]) + + assert captured == { + "target": "local_diff", + "request": [command], + "output_path": str(output), + } + + +@pytest.mark.parametrize("command", ["config", "settings", "help"]) +def test_compatible_non_model_commands_write_markdown_artifacts( + command, monkeypatch, tmp_path, +): + monkeypatch.setattr("sys.stdin", io.StringIO(_DIFF)) + output = tmp_path / f"{command}.md" + + run(inargs=["--stdin", "--output", str(output), command]) + + assert output.read_text(encoding="utf-8").strip() + + +def test_auto_review_writes_markdown_artifact_with_stubbed_model( + monkeypatch, tmp_path, +): + async def fake_chat_completion(self, model, system, user, temperature=0.2, **kwargs): + return _CANNED_REVIEW, "stop" + + monkeypatch.setattr( + "pr_agent.algo.ai_handlers.litellm_ai_handler.LiteLLMAIHandler.chat_completion", + fake_chat_completion, + ) + monkeypatch.setattr("sys.stdin", io.StringIO(_DIFF)) + output = tmp_path / "auto-review.md" + + run(inargs=["--stdin", "--output", str(output), "auto_review"]) + + assert _REVIEW_MARKER in output.read_text(encoding="utf-8") + + +def test_answer_output_is_rejected_without_creating_artifact(monkeypatch, tmp_path, capsys): + input_args = _rejecting_input_args("stdin", monkeypatch) + output = tmp_path / "answer.md" + + with pytest.raises(SystemExit) as exc_info: + run(inargs=[*input_args, "--output", str(output), "answer"]) + + assert exc_info.value.code == 2 + assert "--output is not supported for" in capsys.readouterr().err + assert not output.exists() + + +@pytest.mark.parametrize("command", ["review", "review_pr"]) +@pytest.mark.parametrize("input_mode", ["stdin", "file"]) +def test_json_output_accepts_review_aliases(command, input_mode, monkeypatch, tmp_path): + captured = {} + + class FakeAgent: + async def handle_request(self, target, request, notify=None): + captured["request"] = request + captured["json_output_path"] = get_settings().plain_diff.json_output_path + return True + + monkeypatch.setattr("pr_agent.cli.PRAgent", FakeAgent) + input_args = _valid_input_args(input_mode, monkeypatch, tmp_path) + + output = tmp_path / "review.json" + run(inargs=[*input_args, "--json-output", str(output), command]) + + assert captured == { + "request": [command], + "json_output_path": str(output), + } + + +@pytest.mark.parametrize("command", [*_NON_MARKDOWN_COMMANDS, *_MARKDOWN_COMMANDS[2:]]) +@pytest.mark.parametrize("input_mode", ["stdin", "file"]) +def test_json_output_rejects_non_review_commands_before_read( + command, input_mode, monkeypatch, capsys, +): + input_args = _rejecting_input_args(input_mode, monkeypatch) + + with pytest.raises(SystemExit) as exc_info: + run(inargs=[*input_args, "--json-output", "out.json", command]) + + assert exc_info.value.code == 2 + assert "--json-output is only supported for plain-diff review commands" in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("option_spelling", "option"), + _OUTPUT_OPTION_PREFIXES, +) +@pytest.mark.parametrize("value_form", ["separate", "equals"]) +def test_output_options_after_command_fail_before_read( + option_spelling, option, value_form, monkeypatch, capsys, +): + class UnreadableStdin: + def read(self): + pytest.fail("stdin must not be read for misplaced output options") + + monkeypatch.setattr("sys.stdin", UnreadableStdin()) + value = "out.json" if option == "--json-output" else "out.md" + trailing_args = ( + [option_spelling, value] + if value_form == "separate" + else [f"{option_spelling}={value}"] + ) + + with pytest.raises(SystemExit) as exc_info: + run(inargs=["--stdin", "review", *trailing_args]) + + assert exc_info.value.code == 2 + err = capsys.readouterr().err + assert f"{option} must appear before the command" in err + + +def test_near_match_after_command_remains_a_tool_argument(monkeypatch): + captured = {} + + class FakeAgent: + async def handle_request(self, target, request, notify=None): + captured["request"] = request + return True + + monkeypatch.setattr("pr_agent.cli.PRAgent", FakeAgent) + monkeypatch.setattr("sys.stdin", io.StringIO(_DIFF)) + + run(inargs=["--stdin", "review", "--output-format=markdown"]) + + assert captured["request"] == ["review", "--output-format=markdown"] def test_diff_mode_forces_publish_output(cfg, monkeypatch): """Diff mode must force config.publish_output=True so stdout/--output is never suppressed by a config/env that disabled publishing.""" - import io - cfg("config.publish_output", False) captured = {} @@ -92,8 +425,6 @@ async def handle_request(self, target, request, notify=None): def test_diff_mode_sets_json_output_path(cfg, monkeypatch, tmp_path): - import io - captured = {} class FakeAgent: diff --git a/tests/unittest/test_clip_tokens.py b/tests/unittest/test_clip_tokens.py index f8355aedf1..e1c9160193 100644 --- a/tests/unittest/test_clip_tokens.py +++ b/tests/unittest/test_clip_tokens.py @@ -1,7 +1,7 @@ from unittest.mock import MagicMock, patch +from pr_agent.algo.token_budget import clip_tokens from pr_agent.algo.token_handler import TokenEncoder -from pr_agent.algo.utils import clip_tokens class TestClipTokens: @@ -258,7 +258,7 @@ def test_logging_on_exception(self): max_tokens = 10 # Patch the logger at the module level where it's imported - with patch('pr_agent.algo.utils.get_logger') as mock_logger: + with patch('pr_agent.algo.token_budget.get_logger') as mock_logger: mock_log_instance = MagicMock() mock_logger.return_value = mock_log_instance diff --git a/tests/unittest/test_clip_tokens_invalid_budget.py b/tests/unittest/test_clip_tokens_invalid_budget.py index bcafa3ed38..135516fe91 100644 --- a/tests/unittest/test_clip_tokens_invalid_budget.py +++ b/tests/unittest/test_clip_tokens_invalid_budget.py @@ -3,7 +3,7 @@ import pytest -from pr_agent.algo.utils import clip_tokens +from pr_agent.algo.token_budget import clip_tokens from pr_agent.log import get_logger LONG_TEXT = "word " * 500 diff --git a/tests/unittest/test_clone_credential_redaction.py b/tests/unittest/test_clone_credential_redaction.py index c36b9eec51..b124310f9a 100644 --- a/tests/unittest/test_clone_credential_redaction.py +++ b/tests/unittest/test_clone_credential_redaction.py @@ -1,8 +1,13 @@ +import base64 +import subprocess +from pathlib import Path from types import SimpleNamespace +from unittest.mock import patch import pytest -from pr_agent.git_providers.git_provider import redact_credentials +from pr_agent.git_providers.bitbucket_server_provider import BitbucketServerProvider +from pr_agent.git_providers.git_provider import GitProvider, redact_credentials @pytest.mark.parametrize("url, expected", [ @@ -31,6 +36,11 @@ def test_url_without_credentials_is_unchanged(): assert redact_credentials(url) == url +def test_url_with_at_in_path_is_unchanged(): + url = "https://github.com/acme/repo@archive/a.git" + assert redact_credentials(url) == url + + @pytest.mark.parametrize("scheme", ["Bearer", "bearer", "Basic", "token"]) def test_authorization_header_value_is_masked(scheme): text = f"http.extraHeader=Authorization: {scheme} SUPER-SECRET-VALUE" @@ -56,6 +66,108 @@ def test_empty_input_is_safe(): assert redact_credentials("") == "" +def test_clone_uses_clean_url_and_http_auth_header(tmp_path): + provider = BitbucketServerProvider.__new__(BitbucketServerProvider) + destination = tmp_path / "checkout" + clean_url = "https://github.example/team/repo.git" + token_url = "https://oauth2:SECRET@github.example/team/repo.git" + expected_header = "Authorization: Basic " + base64.b64encode(b"oauth2:SECRET").decode("ascii") + + captured = {} + + provider._prepare_clone_url_with_token = lambda _url: token_url + provider._clone_inner = GitProvider._clone_inner.__get__(provider, BitbucketServerProvider) + with patch("pr_agent.git_providers.git_provider.get_git_ssl_env", return_value={}), \ + patch("pr_agent.git_providers.git_provider.subprocess.run") as run: + result = provider.clone(clean_url, str(destination)) + captured["url"] = run.call_args.args[0][-2] + captured["env"] = run.call_args.kwargs["env"] + + assert result is not None + assert captured["url"] == clean_url + assert captured["env"]["GIT_CONFIG_COUNT"] == "1" + assert captured["env"]["GIT_CONFIG_KEY_0"] == "http.extraHeader" + assert captured["env"]["GIT_CONFIG_VALUE_0"] == expected_header + assert "SECRET" not in captured["url"] + + +def test_clone_preserves_inherited_git_config_entries(tmp_path): + provider = BitbucketServerProvider.__new__(BitbucketServerProvider) + provider._prepare_clone_url_with_token = lambda _url: "https://oauth2:SECRET@example/repo.git" + provider._clone_inner = GitProvider._clone_inner.__get__(provider, BitbucketServerProvider) + + expected_header = "Authorization: Basic " + base64.b64encode(b"oauth2:SECRET").decode("ascii") + + with patch("pr_agent.git_providers.git_provider.get_git_ssl_env", return_value={ + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "http.proxy", + "GIT_CONFIG_VALUE_0": "http://proxy.example", + }), patch("pr_agent.git_providers.git_provider.subprocess.run") as run: + provider.clone("https://example/repo.git", str(tmp_path / "checkout")) + + env = run.call_args.kwargs["env"] + assert env["GIT_CONFIG_COUNT"] == "2" + assert env["GIT_CONFIG_KEY_0"] == "http.proxy" + assert env["GIT_CONFIG_VALUE_0"] == "http://proxy.example" + assert env["GIT_CONFIG_KEY_1"] == "http.extraHeader" + assert env["GIT_CONFIG_VALUE_1"] == expected_header + + +def test_failed_clone_removes_partial_checkout(tmp_path): + provider = BitbucketServerProvider.__new__(BitbucketServerProvider) + destination = tmp_path / "checkout" + + def fake_clone(_url, folder, _timeout): + Path(folder, ".git").mkdir(parents=True) + raise RuntimeError("clone failed") + + provider._prepare_clone_url_with_token = lambda _url: "https://oauth2:SECRET@example/repo.git" + provider._clone_inner = fake_clone + with patch("pr_agent.git_providers.git_provider.get_logger"): + result = provider.clone("https://example/repo.git", str(destination)) + + assert result is None + assert not destination.exists() + + +def test_timeout_clone_removes_partial_checkout(tmp_path): + provider = BitbucketServerProvider.__new__(BitbucketServerProvider) + destination = tmp_path / "checkout" + + def fake_clone(_url, folder, _timeout): + Path(folder, ".git").mkdir(parents=True) + raise subprocess.TimeoutExpired("git clone", _timeout) + + provider._prepare_clone_url_with_token = lambda _url: "https://oauth2:SECRET@example/repo.git" + provider._clone_inner = fake_clone + with patch("pr_agent.git_providers.git_provider.get_logger"): + result = provider.clone("https://example/repo.git", str(destination)) + + assert result is None + assert not destination.exists() + + +def test_failed_clone_preserves_existing_destination_when_requested(tmp_path): + provider = BitbucketServerProvider.__new__(BitbucketServerProvider) + destination = tmp_path / "checkout" + destination.mkdir() + marker = destination / "keep.txt" + marker.write_text("caller-owned") + + def fake_clone(_url, folder, _timeout): + Path(folder, ".git").mkdir() + raise RuntimeError("clone failed") + + provider._prepare_clone_url_with_token = lambda _url: "https://oauth2:SECRET@example/repo.git" + provider._clone_inner = fake_clone + with patch("pr_agent.git_providers.git_provider.get_logger"): + result = provider.clone("https://example/repo.git", str(destination), remove_dest_folder=False) + + assert result is None + assert marker.read_text() == "caller-owned" + assert not (destination / ".git").exists() + + class TestCloneUrlValidationLogs: """Keep credentials out of the validation-failure logs in _prepare_clone_url_with_token. diff --git a/tests/unittest/test_code_suggestions_comment_identity.py b/tests/unittest/test_code_suggestions_comment_identity.py index 8e942131db..a71d40334e 100644 --- a/tests/unittest/test_code_suggestions_comment_identity.py +++ b/tests/unittest/test_code_suggestions_comment_identity.py @@ -3,7 +3,7 @@ import pytest -from pr_agent.algo.utils import ( +from pr_agent.algo.comment_identity import ( PRCodeSuggestionsIdentity, add_comment_identity, comment_matches_identity, diff --git a/tests/unittest/test_codecommit_client.py b/tests/unittest/test_codecommit_client.py index caa975bd07..6a19d49a7f 100644 --- a/tests/unittest/test_codecommit_client.py +++ b/tests/unittest/test_codecommit_client.py @@ -120,6 +120,7 @@ def test_get_pr(self): "sourceReference": "branch1", "destinationCommit": "commit2", "destinationReference": "branch2", + "mergeBase": "merge-base", "repositoryName": "my_test_repo", } ], @@ -136,6 +137,7 @@ def test_get_pr(self): assert pr.targets[0].source_branch == "branch1" assert pr.targets[0].destination_commit == "commit2" assert pr.targets[0].destination_branch == "branch2" + assert pr.targets[0].merge_base == "merge-base" def test_get_pr_preserves_all_target_repositories(self): api = CodeCommitClient() @@ -148,11 +150,13 @@ def test_get_pr_preserves_all_target_repositories(self): "repositoryName": "repo-one", "sourceCommit": "source-one", "destinationCommit": "destination-one", + "mergeBase": "base-one", }, { "repositoryName": "repo-two", "sourceCommit": "source-two", "destinationCommit": "destination-two", + "mergeBase": "base-two", }, ], } @@ -160,7 +164,70 @@ def test_get_pr_preserves_all_target_repositories(self): pr = api.get_pr("repo-one", 321) - assert [(target.repository_name, target.source_commit, target.destination_commit) for target in pr.targets] == [ - ("repo-one", "source-one", "destination-one"), - ("repo-two", "source-two", "destination-two"), + assert [ + (target.repository_name, target.source_commit, target.destination_commit, target.merge_base) + for target in pr.targets + ] == [ + ("repo-one", "source-one", "destination-one", "base-one"), + ("repo-two", "source-two", "destination-two", "base-two"), ] + + def test_get_comments_for_pull_request_uses_safe_page_size_and_all_pages(self): + api = CodeCommitClient() + api.boto_client = MagicMock() + paginator = api.boto_client.get_paginator.return_value + paginator.paginate.return_value = [ + {"commentsForPullRequestData": [{"comments": [{"commentId": "comment-1"}]}]}, + {"commentsForPullRequestData": [{"comments": [{"commentId": "comment-2"}]}]}, + ] + + comments = api.get_comments_for_pull_request(321) + + assert comments == [ + {"comments": [{"commentId": "comment-1"}]}, + {"comments": [{"commentId": "comment-2"}]}, + ] + api.boto_client.get_paginator.assert_called_once_with("get_comments_for_pull_request") + paginator.paginate.assert_called_once_with( + pullRequestId="321", + PaginationConfig={"PageSize": 100}, + ) + + def test_publish_comment_returns_boto_response(self): + api = CodeCommitClient() + api.boto_client = MagicMock() + api.boto_client.post_comment_for_pull_request.return_value = { + "comment": {"commentId": "comment-1", "content": "Review"} + } + + response = api.publish_comment( + repo_name="my_test_repo", + pr_number=321, + destination_commit="destination-commit", + source_commit="source-commit", + comment="Review", + ) + + assert response == {"comment": {"commentId": "comment-1", "content": "Review"}} + api.boto_client.post_comment_for_pull_request.assert_called_once_with( + pullRequestId="321", + repositoryName="my_test_repo", + beforeCommitId="destination-commit", + afterCommitId="source-commit", + content="Review", + ) + + def test_update_comment_returns_boto_response(self): + api = CodeCommitClient() + api.boto_client = MagicMock() + api.boto_client.update_comment.return_value = { + "comment": {"commentId": "comment-1", "content": "Updated review"} + } + + response = api.update_comment("comment-1", "Updated review") + + assert response == {"comment": {"commentId": "comment-1", "content": "Updated review"}} + api.boto_client.update_comment.assert_called_once_with( + commentId="comment-1", + content="Updated review", + ) diff --git a/tests/unittest/test_codecommit_provider.py b/tests/unittest/test_codecommit_provider.py index 09a42e5782..8fd27ba912 100644 --- a/tests/unittest/test_codecommit_provider.py +++ b/tests/unittest/test_codecommit_provider.py @@ -1,9 +1,13 @@ +from datetime import datetime, timezone +from types import SimpleNamespace from unittest.mock import MagicMock, call, patch import pytest +from pr_agent.algo.comment_identity import PRReviewHeader, PRReviewIdentity from pr_agent.algo.types import EDIT_TYPE from pr_agent.git_providers.codecommit_provider import CodeCommitFile, CodeCommitProvider, PullRequestCCMimic +from pr_agent.tools.pr_reviewer import PRReviewer class TestCodeCommitFile: @@ -15,8 +19,16 @@ def test_valid_parameters(self): b_path = "path/to/file_b" b_blob_id = "67890" edit_type = EDIT_TYPE.ADDED + comparison_base_commit = "merge-base" - file = CodeCommitFile(a_path, a_blob_id, b_path, b_blob_id, edit_type) + file = CodeCommitFile( + a_path, + a_blob_id, + b_path, + b_blob_id, + edit_type, + comparison_base_commit=comparison_base_commit, + ) assert file.a_path == a_path assert file.a_blob_id == a_blob_id @@ -24,6 +36,7 @@ def test_valid_parameters(self): assert file.b_blob_id == b_blob_id assert file.edit_type == edit_type assert file.filename == b_path + assert file.comparison_base_commit == comparison_base_commit class TestCodeCommitProvider: @@ -39,6 +52,75 @@ def _make_diff_provider(git_files): provider.codecommit_client = MagicMock() return provider + @staticmethod + def _make_persistent_provider(targets=None): + provider = object.__new__(CodeCommitProvider) + provider.repo_name = "source-repository" + provider.pr_num = 321 + provider.pr_url = ( + "https://us-east-1.console.aws.amazon.com/codesuite/codecommit/" + "repositories/source-repository/pull-requests/321" + ) + provider.diff_files = None + provider.git_files = [] + provider.codecommit_client = MagicMock() + targets = targets or [ + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-1", + source_branch="refs/heads/feature", + destination_commit="destination-commit-1", + destination_branch="refs/heads/main", + merge_base="merge-base-1", + ) + ] + provider.pr = PullRequestCCMimic("Persistent PR", [], targets=targets) + provider.pr.source_commit = targets[0].source_commit + provider.pr.source_branch = targets[0].source_branch + provider.pr.destination_commit = targets[0].destination_commit + provider.pr.destination_branch = targets[0].destination_branch + return provider + + @staticmethod + def _comment( + body, + comment_id="comment-1", + created_at=None, + last_modified_at=None, + **overrides, + ): + comment = { + "commentId": comment_id, + "content": body, + "creationDate": created_at or datetime(2024, 1, 1, tzinfo=timezone.utc), + "lastModifiedDate": last_modified_at or created_at or datetime(2024, 1, 1, tzinfo=timezone.utc), + "authorArn": "arn:aws:iam::123456789012:user/pr-agent", + } + comment.update(overrides) + return comment + + @staticmethod + def _comment_group( + *comments, + repository_name="source-repository", + before_commit_id="old-destination-commit", + after_commit_id="old-source-commit", + location=None, + ): + group = { + "repositoryName": repository_name, + "beforeCommitId": before_commit_id, + "afterCommitId": after_commit_id, + "comments": list(comments), + } + if location is not None: + group["location"] = location + return group + + @staticmethod + def _successful_update_response(comment_id, body): + return {"comment": {"commentId": comment_id, "content": body, "deleted": False}} + def test_get_diff_files_includes_deleted_file(self): provider = object.__new__(CodeCommitProvider) provider.repo_name = "my_test_repo" @@ -127,7 +209,91 @@ def test_get_diff_files_filters_invalid_extension_before_fetching_content(self): call("my_test_repo", "good.py", "source-commit"), ] - def test_get_diff_files_does_not_swallow_client_errors(self): + def test_get_diff_files_applies_ignore_rules_before_fetching_content(self): + ignored_file = CodeCommitFile( + "vendor/generated.py", "before-id", "vendor/generated.py", "after-id", EDIT_TYPE.MODIFIED + ) + valid_file = CodeCommitFile("good.py", "before-id", "good.py", "after-id", EDIT_TYPE.MODIFIED) + provider = self._make_diff_provider([ignored_file, valid_file]) + provider.codecommit_client.get_file.side_effect = ( + lambda _repo_name, _path, commit: b"before\n" if commit == "destination-commit" else b"after\n" + ) + + settings = MagicMock() + settings.ignore.regex = [] + settings.ignore.glob = ["vendor/**"] + settings.config.get.return_value = [] + + with patch("pr_agent.algo.file_filter.get_settings", return_value=settings): + diff_files = provider.get_diff_files() + + assert [diff_file.filename for diff_file in diff_files] == ["good.py"] + assert provider.codecommit_client.get_file.call_args_list == [ + call("my_test_repo", "good.py", "destination-commit"), + call("my_test_repo", "good.py", "source-commit"), + ] + + def test_get_diff_files_does_not_apply_first_target_ignore_rules_to_other_targets(self): + first_target = SimpleNamespace( + repository_name="repo-one", + source_commit="source-1", + destination_commit="destination-1", + ) + second_target = SimpleNamespace( + repository_name="repo-two", + source_commit="source-2", + destination_commit="destination-2", + ) + files = [ + CodeCommitFile( + "one.py", "before-1", "one.py", "after-1", EDIT_TYPE.MODIFIED, + repository_name="repo-one", + source_commit="source-1", + destination_commit="destination-1", + comparison_base_commit="destination-1", + ), + CodeCommitFile( + "two.py", "before-2", "two.py", "after-2", EDIT_TYPE.MODIFIED, + repository_name="repo-two", + source_commit="source-2", + destination_commit="destination-2", + comparison_base_commit="destination-2", + ), + ] + provider = self._make_diff_provider(files) + provider.pr.targets = [first_target, second_target] + provider.codecommit_client.get_file.side_effect = ( + lambda _repo, _path, commit: b"before\n" if commit.startswith("destination-") else b"after\n" + ) + + with patch( + "pr_agent.git_providers.codecommit_provider.filter_ignored", + return_value=[], + ) as filter_ignored: + diff_files = provider.get_diff_files() + + assert [diff_file.filename for diff_file in diff_files] == ["one.py", "two.py"] + filter_ignored.assert_not_called() + + def test_get_diff_files_caches_empty_filtered_result(self): + ignored_file = CodeCommitFile( + "vendor/generated.py", "before-id", "vendor/generated.py", "after-id", EDIT_TYPE.MODIFIED + ) + provider = self._make_diff_provider([ignored_file]) + + with patch( + "pr_agent.git_providers.codecommit_provider.filter_ignored", + return_value=[], + ) as filter_ignored: + first_result = provider.get_diff_files() + second_result = provider.get_diff_files() + + assert first_result == [] + assert second_result is first_result + filter_ignored.assert_called_once() + provider.codecommit_client.get_file.assert_not_called() + + def test_get_diff_files_retries_after_client_error(self): file = CodeCommitFile("file.py", "before-id", "file.py", "after-id", EDIT_TYPE.MODIFIED) provider = self._make_diff_provider([file]) provider.codecommit_client.get_file.side_effect = ValueError("AWS request failed") @@ -135,6 +301,46 @@ def test_get_diff_files_does_not_swallow_client_errors(self): with pytest.raises(ValueError, match="AWS request failed"): provider.get_diff_files() + assert provider.diff_files is None + + provider.codecommit_client.get_file.side_effect = ( + lambda _repo_name, _path, commit: b"before\n" if commit == "destination-commit" else b"after\n" + ) + + diff_files = provider.get_diff_files() + + assert [diff_file.filename for diff_file in diff_files] == ["file.py"] + assert diff_files[0].base_file == "before\n" + assert diff_files[0].head_file == "after\n" + assert "-before" in diff_files[0].patch + assert "+after" in diff_files[0].patch + + def test_get_repo_settings_ignores_source_branch_config(self): + provider = self._make_persistent_provider() + source_settings = b"[ignore]\nglob = ['**']\n" + destination_settings = b"[ignore]\nglob = ['vendor/**']\n" + + def get_file(_repository, _path, commit, optional=False): + assert optional is True + if commit == "source-commit-1": + return source_settings + if commit == "destination-commit-1": + return destination_settings + raise AssertionError(f"unexpected commit: {commit}") + + provider.codecommit_client.get_file.side_effect = get_file + + settings = provider.get_repo_settings() + + assert settings == destination_settings + assert settings != source_settings + provider.codecommit_client.get_file.assert_called_once_with( + "source-repository", + ".pr_agent.toml", + "destination-commit-1", + optional=True, + ) + def test_get_files_includes_differences_from_every_pull_request_target(self): provider = object.__new__(CodeCommitProvider) provider.repo_name = "source-repository" @@ -149,6 +355,7 @@ def test_get_files_includes_differences_from_every_pull_request_target(self): source_branch="feature/one", destination_commit="destination-commit-1", destination_branch="main", + merge_base="merge-base-1", ) second_target = MagicMock( repository_name="destination-repository", @@ -156,18 +363,50 @@ def test_get_files_includes_differences_from_every_pull_request_target(self): source_branch="feature/two", destination_commit="destination-commit-2", destination_branch="release", + merge_base="merge-base-2", ) provider.codecommit_client.get_pr.return_value = MagicMock( title="Multi-target PR", description="Review both targets", targets=[first_target, second_target], ) - provider.codecommit_client.get_differences.side_effect = [ - [MagicMock(before_blob_path="one.py", before_blob_id="before-1", - after_blob_path="one.py", after_blob_id="after-1", change_type="M")], - [MagicMock(before_blob_path="two.py", before_blob_id="before-2", - after_blob_path="two.py", after_blob_id="after-2", change_type="M")], - ] + differences_by_repository = { + "source-repository": [ + MagicMock( + before_blob_path="one.py", + before_blob_id="before-1", + after_blob_path="one.py", + after_blob_id="after-1", + change_type="M", + ) + ], + "destination-repository": [ + MagicMock( + before_blob_path="two.py", + before_blob_id="before-2", + after_blob_path="two.py", + after_blob_id="after-2", + change_type="M", + ) + ], + } + + def get_differences(repository, comparison_base, _source_commit): + differences = differences_by_repository[repository] + if comparison_base.startswith("destination-"): + return [ + MagicMock( + before_blob_path="target-only.py", + before_blob_id="target-only-before", + after_blob_path="", + after_blob_id="", + change_type="D", + ), + *differences, + ] + return differences + + provider.codecommit_client.get_differences.side_effect = get_differences provider.pr = provider._get_pr() files = provider.get_files() @@ -176,20 +415,23 @@ def test_get_files_includes_differences_from_every_pull_request_target(self): "source-repository", "destination-repository" ] assert [file.filename for file in files] == ["one.py", "two.py"] - assert [(file.repository_name, file.destination_commit, file.source_commit) for file in files] == [ - ("source-repository", "destination-commit-1", "source-commit-1"), - ("destination-repository", "destination-commit-2", "source-commit-2"), + assert [ + (file.repository_name, file.comparison_base_commit, file.destination_commit, file.source_commit) + for file in files + ] == [ + ("source-repository", "merge-base-1", "destination-commit-1", "source-commit-1"), + ("destination-repository", "merge-base-2", "destination-commit-2", "source-commit-2"), ] assert provider.codecommit_client.get_differences.call_args_list == [ - call("source-repository", "destination-commit-1", "source-commit-1"), - call("destination-repository", "destination-commit-2", "source-commit-2"), + call("source-repository", "merge-base-1", "source-commit-1"), + call("destination-repository", "merge-base-2", "source-commit-2"), ] provider.codecommit_client.get_file.side_effect = ( lambda repository, path, commit: { - ("source-repository", "one.py", "destination-commit-1"): b"before one\n", + ("source-repository", "one.py", "merge-base-1"): b"before one\n", ("source-repository", "one.py", "source-commit-1"): b"after one\n", - ("destination-repository", "two.py", "destination-commit-2"): b"before two\n", + ("destination-repository", "two.py", "merge-base-2"): b"before two\n", ("destination-repository", "two.py", "source-commit-2"): b"after two\n", }[(repository, path, commit)] ) @@ -200,6 +442,97 @@ def test_get_files_includes_differences_from_every_pull_request_target(self): ("two.py", "before two\n", "after two\n"), ] + def test_get_files_falls_back_to_destination_when_merge_base_is_missing(self): + provider = object.__new__(CodeCommitProvider) + provider.repo_name = "source-repository" + provider.git_files = None + provider.codecommit_client = MagicMock() + provider.codecommit_client.get_differences.return_value = [] + target = SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit", + destination_commit="destination-commit", + merge_base="", + ) + provider.pr = PullRequestCCMimic("Legacy target", [], targets=[target]) + provider.pr.source_commit = target.source_commit + provider.pr.destination_commit = target.destination_commit + + assert provider.get_files() == [] + provider.codecommit_client.get_differences.assert_called_once_with( + "source-repository", "destination-commit", "source-commit" + ) + + def test_prepare_comment_body_caps_at_codecommit_limit(self): + # PostCommentForPullRequest rejects bodies above 10,240 characters and + # publish_comment raises instead of degrading (#3272). The cap must be + # measured AFTER the newline doubling, which grows the body. + provider = self._make_persistent_provider() + body = "\n".join(["x" * 100] * 120) # 12,099 chars before doubling + + prepared = provider._prepare_comment_body(body) + + assert len(prepared) <= 10240 + assert prepared.endswith("...") + assert "\n\n" in prepared + + def test_prepare_comment_body_keeps_review_state_marker_parseable(self): + # The reviewer budgets the hidden state marker before the newline + # doubling; once doubled, the body can exceed the cap. Only the human + # text may be truncated, or the next run cannot read the state. + from pr_agent.algo.review_finding_state import append_review_state, parse_review_state + + provider = self._make_persistent_provider() + state = { + "schema_version": 1, + "last_run": {"commit": "abc123"}, + "findings": [ + {"finding_id": f"f{i}", "state": "ACTIVE", "path": "a.py", "body": "x" * 40} + for i in range(20) + ], + } + review = "\n".join(["r" * 24] * 400) + body = append_review_state(review, state, max_chars=10240 - 3) + assert len(body) <= 10240 - 3 + + prepared = provider._prepare_comment_body(body) + + assert len(prepared) <= 10240 + parsed = parse_review_state(prepared) + assert parsed.valid + assert [f["finding_id"] for f in parsed.state["findings"]] == [f"f{i}" for i in range(20)] + assert prepared.rstrip().endswith("-->") + assert "..." in prepared + + def test_class_docstring_survives_the_comment_limit_attribute(self): + assert CodeCommitProvider.__doc__ is not None + assert "CodeCommit" in CodeCommitProvider.__doc__ + + def test_prepare_comment_body_leaves_short_comment_alone(self): + provider = self._make_persistent_provider() + + assert provider._prepare_comment_body("line one\nline two") == "line one\n\nline two" + + def test_publish_comment_sends_capped_body(self): + provider = self._make_persistent_provider() + provider.codecommit_client.publish_comment.return_value = {"comment": {}} + + provider.publish_comment("\n".join(["y" * 100] * 120)) + + sent = provider.codecommit_client.publish_comment.call_args.kwargs["comment"] + assert len(sent) <= 10240 + assert sent.endswith("...") + + def test_edit_comment_sends_capped_body(self): + provider = self._make_persistent_provider() + provider.codecommit_client.update_comment.return_value = {"comment": {}} + + provider.edit_comment({"id": "comment-1"}, "\n".join(["z" * 100] * 120)) + + sent = provider.codecommit_client.update_comment.call_args.args[1] + assert len(sent) <= 10240 + assert sent.endswith("...") + def test_publish_comment_uses_every_pull_request_target(self): provider = object.__new__(CodeCommitProvider) provider.repo_name = "source-repository" @@ -213,11 +546,13 @@ def test_publish_comment_uses_every_pull_request_target(self): repository_name="source-repository", source_commit="source-commit-1", destination_commit="destination-commit-1", + merge_base="merge-base-1", ), MagicMock( repository_name="destination-repository", source_commit="source-commit-2", destination_commit="destination-commit-2", + merge_base="merge-base-2", ), ], ) @@ -241,6 +576,482 @@ def test_publish_comment_uses_every_pull_request_target(self): ), ] + def test_persistent_review_creates_then_updates_each_pull_request_target(self): + targets = [ + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-1", + source_branch="refs/heads/feature", + destination_commit="destination-commit-1", + destination_branch="refs/heads/main", + merge_base="merge-base-1", + ), + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-2", + source_branch="refs/heads/feature", + destination_commit="destination-commit-2", + destination_branch="refs/heads/release", + merge_base="merge-base-2", + ), + ] + provider = self._make_persistent_provider(targets=targets) + provider.codecommit_client.get_comments_for_pull_request.return_value = [] + + def publish_comment(**kwargs): + return { + "comment": self._comment( + kwargs["comment"], + comment_id=f"comment-{kwargs['source_commit']}", + ) + } + + provider.codecommit_client.publish_comment.side_effect = publish_comment + provider.codecommit_client.update_comment.side_effect = self._successful_update_response + header = "## Team Review" + + created = provider.publish_persistent_comment( + f"{header}\n\nfirst review", + initial_header=header, + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + first_bodies = { + call.kwargs["source_commit"]: call.kwargs["comment"] + for call in provider.codecommit_client.publish_comment.call_args_list + } + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group( + self._comment(first_bodies["source-commit-1"], comment_id="comment-source-commit-1"), + before_commit_id="destination-commit-1", + after_commit_id="source-commit-1", + ), + self._comment_group( + self._comment(first_bodies["source-commit-2"], comment_id="comment-source-commit-2"), + before_commit_id="destination-commit-2", + after_commit_id="source-commit-2", + ), + ] + provider.codecommit_client.publish_comment.reset_mock() + + updated = provider.publish_persistent_comment( + f"{header}\n\nsecond review", + initial_header=header, + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + assert [comment.id for comment in created] == ["comment-source-commit-1", "comment-source-commit-2"] + assert [comment.id for comment in updated] == ["comment-source-commit-1", "comment-source-commit-2"] + provider.codecommit_client.publish_comment.assert_not_called() + assert provider.codecommit_client.update_comment.call_count == 2 + update_bodies = { + call.args[0]: call.args[1] + for call in provider.codecommit_client.update_comment.call_args_list + } + assert set(update_bodies) == {"comment-source-commit-1", "comment-source-commit-2"} + assert PRReviewIdentity.REGULAR.value in first_bodies["source-commit-1"] + assert PRReviewIdentity.REGULAR.value in update_bodies["comment-source-commit-1"] + assert PRReviewIdentity.REGULAR.value in update_bodies["comment-source-commit-2"] + assert "second review" in update_bodies["comment-source-commit-1"] + assert "Review updated until commit source-commit-1" in update_bodies["comment-source-commit-1"] + assert "Review updated until commit source-commit-2" in update_bodies["comment-source-commit-2"] + + def test_persistent_review_updates_comment_from_older_commit_pair_for_single_target(self): + provider = self._make_persistent_provider() + existing_body = f"## Team Review\n\n{PRReviewIdentity.REGULAR.value}\n\nprevious review" + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group( + self._comment(existing_body, comment_id="older-commit-comment"), + before_commit_id="older-destination-commit", + after_commit_id="older-source-commit", + ) + ] + provider.codecommit_client.update_comment.side_effect = self._successful_update_response + + result = provider.publish_persistent_comment( + "## Team Review\n\nnew review", + initial_header="## Team Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + assert result.id == "older-commit-comment" + provider.codecommit_client.publish_comment.assert_not_called() + comment_id, updated_body = provider.codecommit_client.update_comment.call_args.args + assert comment_id == "older-commit-comment" + assert "Review updated until commit source-commit-1" in updated_body + + def test_persistent_review_falls_back_to_new_comment_when_comment_listing_fails(self): + provider = self._make_persistent_provider() + provider.codecommit_client.get_comments_for_pull_request.side_effect = ValueError("AccessDenied") + provider.codecommit_client.publish_comment.return_value = { + "comment": self._comment("created body", comment_id="fallback-comment") + } + + result = provider.publish_persistent_comment( + "## Team Review\n\nnew review", + initial_header="## Team Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + assert result.id == "fallback-comment" + provider.codecommit_client.update_comment.assert_not_called() + provider.codecommit_client.publish_comment.assert_called_once() + assert PRReviewIdentity.REGULAR.value in provider.codecommit_client.publish_comment.call_args.kwargs["comment"] + + def test_persistent_review_does_not_use_repository_only_match_for_multiple_targets(self): + targets = [ + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-1", + source_branch="refs/heads/feature", + destination_commit="destination-commit-1", + destination_branch="refs/heads/main", + ), + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-2", + source_branch="refs/heads/feature", + destination_commit="destination-commit-2", + destination_branch="refs/heads/release", + ), + ] + provider = self._make_persistent_provider(targets=targets) + existing_body = f"## Team Review\n\n{PRReviewIdentity.REGULAR.value}\n\nprevious review" + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group( + self._comment(existing_body, comment_id="unmatched-comment"), + before_commit_id="older-destination-commit", + after_commit_id="older-source-commit", + ) + ] + + provider.publish_persistent_comment( + "## Team Review\n\nnew review", + initial_header="## Team Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + provider.codecommit_client.update_comment.assert_not_called() + assert provider.codecommit_client.publish_comment.call_args_list == [ + call( + repo_name="source-repository", + pr_number=321, + destination_commit="destination-commit-1", + source_commit="source-commit-1", + comment=provider.codecommit_client.publish_comment.call_args_list[0].kwargs["comment"], + ), + call( + repo_name="source-repository", + pr_number=321, + destination_commit="destination-commit-2", + source_commit="source-commit-2", + comment=provider.codecommit_client.publish_comment.call_args_list[1].kwargs["comment"], + ), + ] + + def test_persistent_review_does_not_use_ambiguous_destination_match_for_multiple_targets(self): + targets = [ + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-a-new", + source_branch="refs/heads/feature-a", + destination_commit="shared-destination-commit", + destination_branch="refs/heads/main", + ), + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-b-new", + source_branch="refs/heads/feature-b", + destination_commit="shared-destination-commit", + destination_branch="refs/heads/main", + ), + ] + provider = self._make_persistent_provider(targets=targets) + existing_body = f"## Team Review\n\n{PRReviewIdentity.REGULAR.value}\n\nprevious review" + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group( + self._comment( + existing_body, + comment_id="target-b-old", + created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + ), + before_commit_id="shared-destination-commit", + after_commit_id="source-commit-b-old", + ), + self._comment_group( + self._comment( + existing_body, + comment_id="target-a-old", + created_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + ), + before_commit_id="shared-destination-commit", + after_commit_id="source-commit-a-old", + ), + ] + + provider.publish_persistent_comment( + "## Team Review\n\nnew review", + initial_header="## Team Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + provider.codecommit_client.update_comment.assert_not_called() + assert [ + call.kwargs["source_commit"] + for call in provider.codecommit_client.publish_comment.call_args_list + ] == ["source-commit-a-new", "source-commit-b-new"] + + @pytest.mark.parametrize("raw_order", [("old", "new"), ("new", "old")]) + def test_get_issue_comments_uses_oldest_first_creation_order_for_answer(self, raw_order): + provider = self._make_persistent_provider() + comments_by_name = { + "old": self._comment( + "older answer", + comment_id="old-comment", + created_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + last_modified_at=datetime(2024, 1, 10, tzinfo=timezone.utc), + ), + "new": self._comment( + "newer answer", + comment_id="new-comment", + created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + ), + } + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group(*(comments_by_name[name] for name in raw_order)) + ] + + comments = provider.get_issue_comments() + + assert provider.is_supported("get_issue_comments") is True + assert provider.supports_review_comment_identity() is True + assert [comment.id for comment in comments] == ["old-comment", "new-comment"] + assert comments[0].user.login == "arn:aws:iam::123456789012:user/pr-agent" + provider.codecommit_client.get_comments_for_pull_request.assert_called_once_with(321) + + @pytest.mark.parametrize("raw_order", [("q-old", "a-old", "q-new", "a-new"), ("a-new", "q-new", "a-old", "q-old")]) + def test_answer_mode_uses_newest_question_and_answer_after_oldest_first_sort(self, raw_order): + provider = self._make_persistent_provider() + bodies_by_name = { + "q-old": "Questions to better understand the PR:\n\nold question", + "a-old": "/answer old answer", + "q-new": "Questions to better understand the PR:\n\nnew question", + "a-new": "/answer new answer", + } + dates_by_name = { + "q-old": datetime(2024, 1, 1, tzinfo=timezone.utc), + "a-old": datetime(2024, 1, 2, tzinfo=timezone.utc), + "q-new": datetime(2024, 1, 3, tzinfo=timezone.utc), + "a-new": datetime(2024, 1, 4, tzinfo=timezone.utc), + } + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group( + *( + self._comment( + bodies_by_name[name], + comment_id=name, + created_at=dates_by_name[name], + ) + for name in raw_order + ) + ) + ] + reviewer = PRReviewer.__new__(PRReviewer) + reviewer.is_answer = True + reviewer.git_provider = provider + + question, answer = reviewer._get_user_answers() + + assert question == bodies_by_name["q-new"] + assert answer == bodies_by_name["a-new"] + + @pytest.mark.parametrize("raw_order", [("old", "new"), ("new", "old")]) + def test_persistent_review_updates_newest_duplicate_under_both_api_feed_orders(self, raw_order): + provider = self._make_persistent_provider() + provider.codecommit_client.update_comment.side_effect = self._successful_update_response + body = f"## Team Review\n\n{PRReviewIdentity.REGULAR.value}\n\nprevious review" + comments_by_name = { + "old": self._comment( + body, + comment_id="old-comment", + created_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + last_modified_at=datetime(2024, 1, 10, tzinfo=timezone.utc), + ), + "new": self._comment( + body, + comment_id="new-comment", + created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + ), + } + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group(*(comments_by_name[name] for name in raw_order)) + ] + + provider.publish_persistent_comment( + "## Team Review\n\nnew review", + initial_header="## Team Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + provider.codecommit_client.update_comment.assert_called_once() + assert provider.codecommit_client.update_comment.call_args.args[0] == "new-comment" + provider.codecommit_client.publish_comment.assert_not_called() + + def test_get_issue_comments_filters_deleted_replies_inline_and_malformed_comments(self): + provider = self._make_persistent_provider() + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + "not a group", + self._comment_group(self._comment("valid old", comment_id="valid-old")), + self._comment_group( + self._comment("reply", comment_id="reply", inReplyTo="valid-old"), + self._comment("deleted", comment_id="deleted", deleted=True), + self._comment("missing id", commentId=None), + self._comment("", comment_id="empty-body"), + self._comment("bad timestamp", comment_id="bad-timestamp", creationDate="yesterday"), + ), + self._comment_group( + self._comment("inline", comment_id="inline"), + location={"filePath": "src/app.py", "filePosition": 7}, + ), + self._comment_group( + self._comment( + "valid new", + comment_id="valid-new", + created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + ) + ), + ] + + comments = provider.get_issue_comments() + + assert [(comment.id, comment.body) for comment in comments] == [ + ("valid-old", "valid old"), + ("valid-new", "valid new"), + ] + + def test_persistent_review_migrates_legacy_heading_to_stable_identity(self): + provider = self._make_persistent_provider() + legacy_body = f"{PRReviewHeader.REGULAR.value} 🔍\n\nprevious review" + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group(self._comment(legacy_body, comment_id="legacy-comment")) + ] + provider.codecommit_client.update_comment.side_effect = self._successful_update_response + + provider.publish_persistent_comment( + "## Custom Review\n\nnew review", + initial_header="## Custom Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + provider.codecommit_client.publish_comment.assert_not_called() + comment_id, updated_body = provider.codecommit_client.update_comment.call_args.args + assert comment_id == "legacy-comment" + assert updated_body.startswith("## Custom Review\n\n") + assert PRReviewIdentity.REGULAR.value in updated_body + + def test_persistent_review_falls_back_to_new_comment_only_for_failed_target_update(self): + targets = [ + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-1", + source_branch="refs/heads/feature", + destination_commit="destination-commit-1", + destination_branch="refs/heads/main", + ), + SimpleNamespace( + repository_name="source-repository", + source_commit="source-commit-2", + source_branch="refs/heads/feature", + destination_commit="destination-commit-2", + destination_branch="refs/heads/release", + ), + ] + provider = self._make_persistent_provider(targets=targets) + existing_body = f"## Team Review\n\n{PRReviewIdentity.REGULAR.value}\n\nprevious review" + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group( + self._comment(existing_body, comment_id="comment-target-1"), + before_commit_id="destination-commit-1", + after_commit_id="source-commit-1", + ), + self._comment_group( + self._comment(existing_body, comment_id="comment-target-2"), + before_commit_id="destination-commit-2", + after_commit_id="source-commit-2", + ), + ] + + def update_comment(comment_id, body): + if comment_id == "comment-target-2": + return {"comment": {"commentId": comment_id, "content": "unexpected body", "deleted": False}} + return self._successful_update_response(comment_id, body) + + provider.codecommit_client.update_comment.side_effect = update_comment + provider.codecommit_client.publish_comment.return_value = { + "comment": self._comment("fallback body", comment_id="fallback-comment") + } + + result = provider.publish_persistent_comment( + "## Team Review\n\nnew review", + initial_header="## Team Review", + final_update_message=False, + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=f"{PRReviewHeader.REGULAR.value} 🔍", + ) + + assert [comment.id for comment in result] == ["comment-target-1", "fallback-comment"] + assert provider.codecommit_client.update_comment.call_count == 2 + provider.codecommit_client.publish_comment.assert_called_once() + assert provider.codecommit_client.publish_comment.call_args.kwargs["repo_name"] == "source-repository" + assert ( + provider.codecommit_client.publish_comment.call_args.kwargs["destination_commit"] + == "destination-commit-2" + ) + assert provider.codecommit_client.publish_comment.call_args.kwargs["source_commit"] == "source-commit-2" + + def test_unmarked_persistent_comment_remains_create_only(self): + provider = self._make_persistent_provider() + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + self._comment_group(self._comment("## Title\n\nold description", comment_id="describe-comment")) + ] + provider.codecommit_client.publish_comment.return_value = { + "comment": self._comment("## Title\n\nnew description", comment_id="new-describe-comment") + } + + result = provider.publish_persistent_comment( + "## Title\n\nnew description", + initial_header="## Title", + update_header=True, + name="describe", + final_update_message=False, + ) + + assert result.id == "new-describe-comment" + provider.codecommit_client.get_comments_for_pull_request.assert_not_called() + provider.codecommit_client.update_comment.assert_not_called() + provider.codecommit_client.publish_comment.assert_called_once() + + def test_get_issue_comments_support_does_not_enable_gfm_improve_history(self): + provider = self._make_persistent_provider() + + assert provider.is_supported("get_issue_comments") is True + assert provider.is_supported("gfm_markdown") is False + def test_publish_code_suggestion_uses_target_that_contains_file(self): provider = object.__new__(CodeCommitProvider) provider.repo_name = "source-repository" @@ -255,11 +1066,13 @@ def test_publish_code_suggestion_uses_target_that_contains_file(self): repository_name="source-repository", source_commit="source-commit-1", destination_commit="destination-commit-1", + merge_base="merge-base-1", ), MagicMock( repository_name="destination-repository", source_commit="source-commit-2", destination_commit="destination-commit-2", + merge_base="merge-base-2", ), ], ) @@ -273,6 +1086,7 @@ def test_publish_code_suggestion_uses_target_that_contains_file(self): repository_name="destination-repository", source_commit="source-commit-2", destination_commit="destination-commit-2", + comparison_base_commit="merge-base-2", ) ] @@ -352,7 +1166,7 @@ def test_is_valid_codecommit_hostname(self): assert not CodeCommitProvider._is_valid_codecommit_hostname("no-such-region.console.aws.amazon.com") assert not CodeCommitProvider._is_valid_codecommit_hostname("console.aws.amazon.com") - # Test that an error is raised when an invalid CodeCommit URL is provided to the set_pr() method of the CodeCommitProvider class. + # Error is raised when set_pr() receives an invalid CodeCommit URL. # Generated by CodiumAI def test_invalid_codecommit_url(self): provider = CodeCommitProvider() @@ -446,8 +1260,20 @@ def test_add_additional_newlines(self): expect = "abc\n\ndef\n\n___\n\nghi\n\njkl\n\nmno\n\npqr\n\n" assert CodeCommitProvider._add_additional_newlines(input) == expect # a test example from a real PR - input = "## PR Type:\nEnhancement\n\n___\n## PR Description:\nThis PR introduces a new feature to the script, allowing users to filter servers by name.\n\n___\n## PR Main Files Walkthrough:\n`foo`: The foo script has been updated to include a new command line option `-f` or `--filter`.\n`bar`: The bar script has been updated to list stopped servers.\n" - expect = "## PR Type:\n\nEnhancement\n\n___\n\n## PR Description:\n\nThis PR introduces a new feature to the script, allowing users to filter servers by name.\n\n___\n\n## PR Main Files Walkthrough:\n\n`foo`: The foo script has been updated to include a new command line option `-f` or `--filter`.\n\n`bar`: The bar script has been updated to list stopped servers.\n\n" + input = ( + "## PR Type:\nEnhancement\n\n___\n## PR Description:\n" + "This PR introduces a new feature to the script, allowing users to filter servers by name.\n\n" + "___\n## PR Main Files Walkthrough:\n" + "`foo`: The foo script has been updated to include a new command line option `-f` or `--filter`.\n" + "`bar`: The bar script has been updated to list stopped servers.\n" + ) + expect = ( + "## PR Type:\n\nEnhancement\n\n___\n\n## PR Description:\n\n" + "This PR introduces a new feature to the script, allowing users to filter servers by name.\n\n" + "___\n\n## PR Main Files Walkthrough:\n\n" + "`foo`: The foo script has been updated to include a new command line option `-f` or `--filter`.\n\n" + "`bar`: The bar script has been updated to list stopped servers.\n\n" + ) assert CodeCommitProvider._add_additional_newlines(input) == expect def test_remove_markdown_html(self): diff --git a/tests/unittest/test_command_descriptions.py b/tests/unittest/test_command_descriptions.py index 4e67007fa3..32cd3c8519 100644 --- a/tests/unittest/test_command_descriptions.py +++ b/tests/unittest/test_command_descriptions.py @@ -9,6 +9,8 @@ from pr_agent.tools.pr_help_message import PRHelpMessage REPO_ROOT = Path(__file__).resolve().parents[2] +CURRENT_DOCS_URL = "https://docs.pr-agent.ai" +RETIRED_DOCS_HOSTS = ("qodo-merge-docs.qodo.ai", "pr-agent-docs.codium.ai") def test_cli_and_bot_help_use_canonical_command_descriptions(): @@ -32,6 +34,24 @@ def test_tool_usage_guides_use_canonical_command_descriptions(usage_guide, comma assert COMMAND_DESCRIPTIONS[command] in usage_guide() +def test_runtime_help_uses_current_documentation_links(): + help_text = "\n".join( + [ + HelpMessage.get_general_commands_text(), + HelpMessage.get_general_bot_help_text(), + HelpMessage.get_review_usage_guide(), + HelpMessage.get_describe_usage_guide(), + HelpMessage.get_ask_usage_guide(), + HelpMessage.get_improve_usage_guide(), + HelpMessage.get_help_docs_usage_guide(), + ] + ) + + assert f"{CURRENT_DOCS_URL}/tools/review/#configuration-options" in help_text + assert f"{CURRENT_DOCS_URL}/tools/ask/" in help_text + assert not any(host in help_text for host in RETIRED_DOCS_HOSTS) + + @pytest.mark.parametrize("command", COMMAND_DESCRIPTIONS) def test_tool_docs_use_canonical_command_descriptions(command): tool_docs = REPO_ROOT / "docs" / "docs" / "tools" / f"{command}.md" @@ -48,6 +68,14 @@ def __init__(self): def is_supported(_feature): return True + @staticmethod + def supports_checkbox_commands(): + return False + + @staticmethod + def supports_markdown_tables(): + return False + def publish_comment(self, comment): self.comment = comment diff --git a/tests/unittest/test_comment_reactions.py b/tests/unittest/test_comment_reactions.py index 7df0f40c37..f39fbb96b2 100644 --- a/tests/unittest/test_comment_reactions.py +++ b/tests/unittest/test_comment_reactions.py @@ -4,12 +4,13 @@ a long command could not tell whether it had finished. `add_reaction` is the provider-level primitive; `add_eyes_reaction` and `react_to_outcome` are the two policies built on it. """ +import asyncio from types import SimpleNamespace from unittest.mock import MagicMock import pytest -from pr_agent.config_loader import get_settings +from pr_agent.config_loader import get_settings, global_settings from pr_agent.git_providers.git_provider import GitProvider, get_reaction_setting from pr_agent.git_providers.github_provider import GithubProvider from pr_agent.git_providers.gitlab_provider import GitLabProvider @@ -309,7 +310,7 @@ async def test_a_successful_comment_command_is_marked(reactions, comment_handler github_app, provider = comment_handler agent = SimpleNamespace() - async def handle_request(api_url, command, notify=None): + async def handle_request(api_url, command, notify=None, **_kwargs): notify() return True @@ -326,7 +327,7 @@ async def test_a_failed_comment_command_is_marked(reactions, comment_handler): github_app, provider = comment_handler agent = SimpleNamespace() - async def handle_request(api_url, command, notify=None): + async def handle_request(api_url, command, notify=None, **_kwargs): notify() return False @@ -343,7 +344,7 @@ async def test_the_default_configuration_adds_only_the_start_reaction(reactions, github_app, provider = comment_handler agent = SimpleNamespace() - async def handle_request(api_url, command, notify=None): + async def handle_request(api_url, command, notify=None, **_kwargs): notify() return True @@ -353,3 +354,175 @@ async def handle_request(api_url, command, notify=None): assert provider.reactions == [(4242, "eyes")] assert provider.removed == [] + + +@pytest.mark.asyncio +async def test_a_swallowed_tool_failure_gets_a_failure_outcome( + reactions, comment_handler, monkeypatch +): + """Keep compatibility-swallowed tool failures from becoming success reactions.""" + import pr_agent.agent.pr_agent as pr_agent_module + + reactions(success="hooray", failure="confused") + monkeypatch.setattr(get_settings().config, "propagate_tool_errors", False, raising=False) + monkeypatch.setattr(pr_agent_module, "apply_repo_settings", lambda _pr_url: None) + monkeypatch.setattr(pr_agent_module.CliArgs, "validate_user_args", lambda _args: (True, None)) + monkeypatch.setattr(pr_agent_module, "update_settings_from_args", lambda args: args) + + observed_propagation = [] + + class SwallowingReviewTool: + def __init__(self, _pr_url, ai_handler=None, args=None): + pass + + async def run(self): + observed_propagation.append( + get_settings().config.get("propagate_tool_errors", False) + ) + try: + raise RuntimeError("provider unavailable") + except RuntimeError: + if get_settings().config.get("propagate_tool_errors", False): + raise + + monkeypatch.setitem(pr_agent_module.command2class, "review", SwallowingReviewTool) + + github_app, provider = comment_handler + agent = pr_agent_module.PRAgent(ai_handler="fake-ai") + + await github_app.handle_comments_on_pr( + _comment_event(), "issue_comment", "user", "1", "created", {}, agent + ) + + assert observed_propagation == [True] + assert provider.reactions == [(4242, "eyes"), (4242, "confused")] + assert provider.removed == [(4242, 1)] + assert get_settings().config.get("propagate_tool_errors") is False + + +@pytest.mark.asyncio +async def test_incomplete_files_constructor_failure_is_visible_and_marked_failed( + reactions, comment_handler, monkeypatch +): + import pr_agent.agent.pr_agent as pr_agent_module + + reactions(failure="confused") + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "apply_repo_settings", lambda _pr_url: None) + monkeypatch.setattr(pr_agent_module.CliArgs, "validate_user_args", lambda _args: (True, None)) + monkeypatch.setattr(pr_agent_module, "update_settings_from_args", lambda args: args) + + class IncompleteReviewTool: + def __init__(self, _pr_url, ai_handler=None, args=None): + raise pr_agent_module.IncompletePullRequestFilesError("private mismatch details") + + monkeypatch.setitem(pr_agent_module.command2class, "review", IncompleteReviewTool) + + github_app, provider = comment_handler + provider.publish_comment = MagicMock() + monkeypatch.setattr( + pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider + ) + agent = pr_agent_module.PRAgent(ai_handler="fake-ai") + + result = await github_app.handle_comments_on_pr( + _comment_event(), "issue_comment", "user", "1", "created", {}, agent + ) + + assert result is False + published = provider.publish_comment.call_args.args[0] + assert "GitHub returned an incomplete or inconsistent changed-file set" in published + assert "If this pull request changes more than 3,000 files" in published + assert "Otherwise, retry the command" in published + assert "private mismatch details" not in published + assert provider.reactions == [(4242, "eyes"), (4242, "confused")] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("command", ["add_docs", "generate_labels"]) +async def test_documentation_and_label_failures_get_failure_outcomes( + reactions, comment_handler, monkeypatch, command +): + """Verify that documentation and label command failures get failure reactions.""" + import pr_agent.agent.pr_agent as pr_agent_module + from pr_agent.tools import pr_add_docs, pr_generate_labels + + reactions(success="hooray", failure="confused") + settings = get_settings() + monkeypatch.setattr(settings.config, "propagate_tool_errors", False, raising=False) + monkeypatch.setattr(settings.config, "publish_output", False, raising=False) + monkeypatch.setattr(pr_agent_module, "apply_repo_settings", lambda _pr_url: None) + monkeypatch.setattr(pr_agent_module.CliArgs, "validate_user_args", lambda _args: (True, None)) + monkeypatch.setattr(pr_agent_module, "update_settings_from_args", lambda args: args) + + tool_module = pr_add_docs if command == "add_docs" else pr_generate_labels + tool_class = pr_agent_module.command2class[command] + + def init_tool(self, *_args, **_kwargs): + self.git_provider = MagicMock() + self.pr_id = "org/repo#1" + + async def fail_before_publishing(*_args, **_kwargs): + raise RuntimeError("provider unavailable") + + monkeypatch.setattr(tool_class, "__init__", init_tool) + monkeypatch.setattr(tool_module, "retry_with_fallback_models", fail_before_publishing) + + github_app, provider = comment_handler + agent = pr_agent_module.PRAgent(ai_handler="fake-ai") + + await github_app.handle_comments_on_pr( + _comment_event(f"/{command}"), "issue_comment", "user", "1", "created", {}, agent + ) + + assert provider.reactions == [(4242, "eyes"), (4242, "confused")] + assert provider.removed == [(4242, 1)] + assert get_settings().config.get("propagate_tool_errors") is False + + +@pytest.mark.asyncio +async def test_contextless_propagation_override_isolated_from_concurrent_requests(monkeypatch): + import pr_agent.agent.pr_agent as pr_agent_module + + assert get_settings() is global_settings + monkeypatch.setattr(global_settings.config, "propagate_tool_errors", False, raising=False) + monkeypatch.setattr(pr_agent_module, "apply_repo_settings", lambda _pr_url: None) + monkeypatch.setattr(pr_agent_module.CliArgs, "validate_user_args", lambda _args: (True, None)) + monkeypatch.setattr(pr_agent_module, "update_settings_from_args", lambda args: args) + + override_started = asyncio.Event() + default_observed = asyncio.Event() + observed = {} + + class WaitingTool: + def __init__(self, pr_url, ai_handler=None, args=None): + self.pr_url = pr_url + + async def run(self): + observed[self.pr_url] = get_settings().config.get("propagate_tool_errors", False) + if self.pr_url == "override": + override_started.set() + await default_observed.wait() + else: + await override_started.wait() + observed["default_during_override"] = get_settings().config.get( + "propagate_tool_errors", False + ) + default_observed.set() + + monkeypatch.setitem(pr_agent_module.command2class, "review", WaitingTool) + agent = pr_agent_module.PRAgent(ai_handler="fake-ai") + + override_result, default_result = await asyncio.gather( + agent.handle_request("override", ["review"], propagate_tool_errors=True), + agent.handle_request("default", ["review"]), + ) + + assert override_result is True + assert default_result is True + assert observed == { + "override": True, + "default": False, + "default_during_override": False, + } + assert global_settings.config.get("propagate_tool_errors") is False diff --git a/tests/unittest/test_compressed_diff_token_sort.py b/tests/unittest/test_compressed_diff_token_sort.py index 201fef1775..c83b5ed062 100644 --- a/tests/unittest/test_compressed_diff_token_sort.py +++ b/tests/unittest/test_compressed_diff_token_sort.py @@ -22,8 +22,14 @@ def test_token_counts_are_filled_in_before_sorting(): small, large = _file("small.py", 1), _file("large.py", 40) assert small.tokens == -1 and large.tokens == -1 - pr_generate_compressed_diff([{"language": "Python", "files": [small, large]}], - FakeTokenHandler(), "gpt-4", False, False) + pr_generate_compressed_diff( + [{"language": "Python", "files": [small, large]}], + FakeTokenHandler(), + 10_000, + 10_000, + False, + False, + ) assert small.tokens > 0 assert large.tokens > small.tokens @@ -34,7 +40,13 @@ def test_files_are_ordered_largest_first(): small, large = _file("small.py", 1), _file("large.py", 40) _, _, _, _, file_dict, _ = pr_generate_compressed_diff( - [{"language": "Python", "files": [small, large]}], FakeTokenHandler(), "gpt-4", False, False) + [{"language": "Python", "files": [small, large]}], + FakeTokenHandler(), + 10_000, + 10_000, + False, + False, + ) assert list(file_dict) == ["large.py", "small.py"] @@ -44,7 +56,13 @@ def test_precomputed_token_counts_are_left_alone(): f = _file("a.py", 5) f.tokens = 12345 - pr_generate_compressed_diff([{"language": "Python", "files": [f]}], - FakeTokenHandler(), "gpt-4", False, False) + pr_generate_compressed_diff( + [{"language": "Python", "files": [f]}], + FakeTokenHandler(), + 10_000, + 10_000, + False, + False, + ) assert f.tokens == 12345 diff --git a/tests/unittest/test_config_dead_keys.py b/tests/unittest/test_config_dead_keys.py new file mode 100644 index 0000000000..6ba2baf978 --- /dev/null +++ b/tests/unittest/test_config_dead_keys.py @@ -0,0 +1,222 @@ +"""Regression guard for dead configuration keys. + +Every key in pr_agent/settings/configuration.toml must be read somewhere in +pr_agent/ source code. Static detection is deliberately conservative: keys are +matched through the common read patterns below, and keys that are genuinely +used through variable indirection (e.g. a settings section assigned to a local +variable and then indexed) are recorded in the allowlist with a note pointing +at the reading site. + +A whole-section read (settings.get("SECTION", ...)) does not count as reading +its keys: each key must be matched through an attribute chain, a quoted +dotted path, or a section-level .get("KEY") / getattr() call, or it must be +allowlisted. + +Adding a key without a reader - or leaving a config-only key in the TOML - +fails this test, so the "dead key ledger" cannot silently regrow. +""" + +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CONFIG_TOML = ROOT / "pr_agent/settings/configuration.toml" +PR_AGENT_SOURCE = ROOT / "pr_agent" + +# Keys read through a settings section stored in a local variable, so the +# static dotted-path scan cannot see them. Each entry documents the reader. +_ALLOWLIST = { + # [artifacts] read as artifacts_settings = get_settings().get("ARTIFACTS", {}) + ( + "artifacts", + "max_artifact_size", + ): "artifacts_settings.get('max_artifact_size', 50000) in pr_agent/algo/artifacts.py", + ( + "artifacts", + "artifact_label", + ): "artifacts_settings.get('artifact_label', '') in pr_agent/algo/artifacts.py", + # [config] helpers that re-index the section from a variable or expression + ( + "config", + "reaction_on_failure", + ): "get_reaction_setting('reaction_on_success' if succeeded else 'reaction_on_failure') " + "reads get_settings().config.get(name) in pr_agent/git_providers/git_provider.py", + ( + "config", + "output_relevant_configurations", + ): "get_settings().get('config', {}).get('output_relevant_configurations', False) in " + "pr_agent/tools/pr_description.py", + ( + "config", + "enable_claude_adaptive_thinking", + ): "settings.config.get(key, default) in the thinking-controls loop in " + "pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "config", + "enable_claude_extended_thinking", + ): "settings.config.get(key, default) in the thinking-controls loop in " + "pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "config", + "extended_thinking_budget_tokens", + ): "settings.config.get(key, default) in the thinking-controls loop in " + "pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "config", + "extended_thinking_max_output_tokens", + ): "settings.config.get(key, default) in the thinking-controls loop in " + "pr_agent/algo/ai_handlers/litellm_ai_handler.py", + # [gerrit] read as gerrit_settings = get_settings().get("gerrit", {}) + ( + "gerrit", + "webhook_username", + ): "gerrit_settings.get('webhook_username', None) in pr_agent/servers/gerrit_server.py", + ( + "gerrit", + "webhook_password", + ): "gerrit_settings.get('webhook_password', None) in pr_agent/servers/gerrit_server.py", + ( + "github", + "api_retries", + ): "github_config = get_settings().github then github_config.get('api_retries') in " + "pr_agent/git_providers/github_provider.py", + ( + "github", + "seconds_between_requests", + ): "github_config.get('seconds_between_requests') in pr_agent/git_providers/github_provider.py", + ( + "github", + "seconds_between_writes", + ): "github_config.get('seconds_between_writes') in pr_agent/git_providers/github_provider.py", + # [openrouter] read as openrouter_settings = settings.get("openrouter", {}) or {} + ( + "openrouter", + "provider_only", + ): "openrouter_settings.get('provider_only', []) in pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "openrouter", + "provider_order", + ): "openrouter_settings.get('provider_order', []) in pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "openrouter", + "allow_fallbacks", + ): "openrouter_settings.get('allow_fallbacks', True) in pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "openrouter", + "reasoning_effort", + ): "openrouter_settings.get('reasoning_effort', '') in pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "openrouter", + "reasoning_max_tokens", + ): "openrouter_settings.get('reasoning_max_tokens', 0) in pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "openrouter", + "max_tokens", + ): "openrouter_settings.get('max_tokens', 0) in pr_agent/algo/ai_handlers/litellm_ai_handler.py", + ( + "pr_description", + "pr_diagram_direction", + ): "description_settings = get_settings().pr_description then " + "description_settings.pr_diagram_direction in pr_agent/tools/pr_description.py", + ( + "pr_description", + "pr_diagram_direction_threshold", + ): "description_settings.pr_diagram_direction_threshold in pr_agent/tools/pr_description.py", + # [push_outputs] read as cfg = get_settings().get("push_outputs", {}) or {} + ( + "push_outputs", + "enable", + ): "cfg.get(\"enable\", False) in pr_agent/algo/run_output.py", + ( + "push_outputs", + "channels", + ): "cfg.get(\"channels\", []) in pr_agent/algo/run_output.py", + ( + "push_outputs", + "file_path", + ): "cfg.get(\"file_path\", \"pr-agent-outputs/reviews.jsonl\") in pr_agent/algo/run_output.py", + ( + "push_outputs", + "webhook_url", + ): "_push_outputs_sink_url(cfg, \"webhook_url\") in pr_agent/algo/run_output.py", + ( + "push_outputs", + "slack_webhook_url", + ): "_push_outputs_sink_url(cfg, \"slack_webhook_url\") in pr_agent/algo/run_output.py", +} + + +def _read_sources() -> str: + return "\n".join( + p.read_text(encoding="utf-8") for p in sorted(PR_AGENT_SOURCE.rglob("*.py")) + ).lower() + + +def _key_is_read(section: str, key: str, sources: str) -> bool: + section_name = re.escape(section) + key_name = re.escape(key) + settings_prefix = ( + r"(?:get_settings\s*\(\s*\)|global_settings|self\s*\.\s*settings|settings)" + ) + # 1. attribute chain: settings.
    . + if re.search( + settings_prefix + r"\s*\.\s*" + section_name + r"\s*\.\s*" + key_name + r"\b", + sources, + ): + return True + # 2. quoted dotted paths: "SECTION.KEY" or "section.key" + for dotted in (f"{section.upper()}.{key.upper()}", f"{section}.{key}"): + if re.search(re.escape(f"\"{dotted}\"") + r"|" + re.escape(f"'{dotted}'"), sources): + return True + # 3. settings.
    .get("KEY") + if re.search( + settings_prefix + r"\s*\.\s*" + section_name + r"\s*\.\s*get\s*\(\s*['\"]" + + key_name + r"['\"]", + sources, + ): + return True + # 4. getattr(settings.
    , "KEY", ...) + if re.search( + r"getattr\s*\(\s*" + settings_prefix + r"\s*\.\s*" + section_name + + r"\s*,\s*['\"]" + key_name + r"['\"]", + sources, + ): + return True + # 5. Detect helpers that read a [config] key through a variable name. + if section == "config": + for helper in ( + "_read_bool_setting", + "get_reaction_setting", + "_validated_model_name_list", + ): + if re.search( + r"\b" + re.escape(helper) + r"\s*\(\s*['\"]" + key_name + r"['\"]", + sources, + ): + return True + return False + + +def test_every_config_key_has_a_reader_or_is_allowlisted(): + sources = _read_sources() + with open(CONFIG_TOML, "rb") as f: + settings = tomllib.load(f) + + unread = { + (section, key) + for section, entries in settings.items() + if not section.startswith("_") + for key in entries + if not _key_is_read(section, key, sources) + } + allowlisted = set(_ALLOWLIST) + + assert unread <= allowlisted, ( + "configuration.toml keys have no detected reader; remove them or allowlist them: " + f"{sorted(unread - allowlisted)}" + ) + assert ( + allowlisted - unread == set() + ), "allowlist has stale entries (those keys are now detected as read or no longer present): " \ + f"{sorted(allowlisted - unread)}" diff --git a/tests/unittest/test_config_reference.py b/tests/unittest/test_config_reference.py new file mode 100644 index 0000000000..7434d2e6e9 --- /dev/null +++ b/tests/unittest/test_config_reference.py @@ -0,0 +1,63 @@ +import re +from collections import Counter +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CONFIG_TOML = ROOT / "pr_agent/settings/configuration.toml" +OUTPUT_PAGE = ROOT / "docs/docs/usage-guide/configuration_reference.md" + + +def _toml_keys(text: str) -> Counter: + return Counter(re.findall(r"^([a-zA-Z0-9_]+)\s*=", text, re.M)) + + +def _documented_keys(text: str) -> Counter: + return Counter( + row.group(1) + for line in text.splitlines() + if line.startswith("| ") + for row in [re.match(r"\| `([^`]+)` \|", line)] + if row + ) + + +def test_config_reference_covers_every_active_key(): + keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) + assert sum(keys.values()) == 268 + + assert { + "model", + "enable_auto_approval", + "reaction_on_start", + "reaction_on_failure", + } <= set(keys) + assert {"force_streaming_custom_llm_provider", "cache_control_injection_points"} <= set(keys) + + +def test_config_reference_page_lists_every_active_key(): + toml_keys = _toml_keys(CONFIG_TOML.read_text(encoding="utf-8")) + documented = _documented_keys(OUTPUT_PAGE.read_text(encoding="utf-8")) + assert documented == toml_keys, ( + "docs/docs/usage-guide/configuration_reference.md is out of date; " + "run scripts/generate_config_reference.py" + ) + + +def test_persistent_inline_comments_description_keeps_skip_phrase(): + """Regression for #3454: a mid-sentence short comment line must not become a heading. + + The generated description for persistent_inline_comments has to retain the + skip/re-post wording; a dropped fragment inverted the feature's meaning. + """ + page = OUTPUT_PAGE.read_text(encoding="utf-8") + row = next( + (line for line in page.splitlines() if line.startswith("| `persistent_inline_comments`")), + None, + ) + assert row is not None, "persistent_inline_comments missing from configuration_reference.md" + lowered = row.lower() + assert "and skip" in lowered or "then skip" in lowered, ( + f"persistent_inline_comments description lost the skip phrase: {row}" + ) + assert "embed a provider-compatible marker" in lowered + assert "**embed a provider-compatible marker, and skip**" not in page diff --git a/tests/unittest/test_config_yaml_rendering.py b/tests/unittest/test_config_yaml_rendering.py index 018b817391..0dcbbe5ce5 100644 --- a/tests/unittest/test_config_yaml_rendering.py +++ b/tests/unittest/test_config_yaml_rendering.py @@ -4,7 +4,7 @@ import pytest import yaml -from pr_agent.algo.utils import show_relevant_configurations +from pr_agent.algo.run_output import show_relevant_configurations from pr_agent.config_loader import get_settings diff --git a/tests/unittest/test_convert_to_markdown.py b/tests/unittest/test_convert_to_markdown.py index a03a51c50a..3d3241d9e2 100644 --- a/tests/unittest/test_convert_to_markdown.py +++ b/tests/unittest/test_convert_to_markdown.py @@ -1,8 +1,11 @@ # Generated by CodiumAI import textwrap -from unittest.mock import Mock +from unittest.mock import Mock, patch -from pr_agent.algo.utils import PRReviewHeader, _expand_minute_suffix, convert_to_markdown_v2 +import pytest + +from pr_agent.algo.comment_identity import PRReviewHeader +from pr_agent.algo.utils import _expand_minute_suffix, convert_to_markdown_v2 from pr_agent.tools.pr_description import insert_br_after_x_chars """ @@ -48,9 +51,12 @@ class TestConvertToMarkdown: # Tests that the function works correctly with a simple dictionary input def test_simple_dictionary_input(self): - input_data = {'review': { - 'estimated_effort_to_review_[1-5]': '1, because the changes are minimal and straightforward, focusing on a single functionality addition.\n', - 'relevant_tests': 'No\n', 'possible_issues': 'No\n', 'security_concerns': 'No\n'}} + input_data = {"review": { + "estimated_effort_to_review_[1-5]": ( + "1, because the changes are minimal and straightforward, " + "focusing on a single functionality addition.\n" + ), + "relevant_tests": "No\n", "possible_issues": "No\n", "security_concerns": "No\n"}} expected_output = textwrap.dedent(f"""\ {PRReviewHeader.REGULAR.value} 🔍 @@ -69,9 +75,12 @@ def test_simple_dictionary_input(self): assert convert_to_markdown_v2(input_data).strip() == expected_output.strip() def test_simple_dictionary_input_without_gfm_supported(self): - input_data = {'review': { - 'estimated_effort_to_review_[1-5]': '1, because the changes are minimal and straightforward, focusing on a single functionality addition.\n', - 'relevant_tests': 'No\n', 'possible_issues': 'No\n', 'security_concerns': 'No\n'}} + input_data = {"review": { + "estimated_effort_to_review_[1-5]": ( + "1, because the changes are minimal and straightforward, " + "focusing on a single functionality addition.\n" + ), + "relevant_tests": "No\n", "possible_issues": "No\n", "security_concerns": "No\n"}} expected_output = textwrap.dedent("""\ ## PR Reviewer Guide 🔍 @@ -123,6 +132,70 @@ def test_key_issues_to_review(self): assert convert_to_markdown_v2(input_data, git_provider=mock_git_provider).strip() == expected_output.strip() mock_git_provider.get_line_link.assert_called_with('src/utils.py', 30, 50) + def test_key_issue_with_non_string_text_is_skipped_without_render_error(self): + input_data = {'review': {'key_issues_to_review': [{ + 'relevant_file': 'src/utils.py', + 'issue_header': 'Code Smell', + 'issue_content': 123, + 'start_line': 30, + 'end_line': 50, + }]}} + + with patch('pr_agent.algo.utils.get_logger') as get_logger: + output = convert_to_markdown_v2(input_data) + + assert 'Recommended focus areas for review' in output + assert 'Code Smell' not in output + get_logger.return_value.exception.assert_not_called() + + @pytest.mark.parametrize("field,invalid_value", [ + ("start_line", ""), + ("start_line", "N/A"), + ("start_line", 12.5), + ("start_line", None), + ("start_line", "12-15"), + ("end_line", ""), + ("end_line", "N/A"), + ("end_line", 12.5), + ("end_line", None), + ("end_line", "12-15"), + ]) + def test_key_issue_with_non_numeric_lines_keeps_finding_in_summary(self, field, invalid_value): + """A non-integer start_line/end_line must not silently drop the finding. + + The review summary, inline comments, and the persisted finding state used to + disagree: inline and state tolerated malformed line fields while the summary + dropped the finding. The finding must render in the summary without lines. + """ + valid_other = {'start_line': 30, 'end_line': 14} + valid_other[field] = invalid_value + input_data = {'review': {'key_issues_to_review': [{ + 'relevant_file': 'src/utils.py', + 'issue_header': 'Possible security issue', + 'issue_content': 'Credentials are logged on the error path.', + **valid_other, + }]}} + mock_git_provider = Mock() + mock_git_provider.get_line_link.return_value = 'https://github.com/qodo/pr-agent/pull/1/files#diff-hash' + + output = convert_to_markdown_v2(input_data, git_provider=mock_git_provider) + + assert 'Possible security issue' in output + assert 'Credentials are logged on the error path.' in output + mock_git_provider.get_line_link.assert_not_called() + + def test_key_issue_with_omitted_text_keeps_empty_fallback(self): + input_data = {'review': {'key_issues_to_review': [{ + 'relevant_file': 'src/utils.py', + 'issue_content': 'The function needs review.', + 'start_line': 30, + 'end_line': 50, + }]}} + + output = convert_to_markdown_v2(input_data) + + assert '
    The function needs review.' in output + def test_ticket_compliance(self): input_data = {'review': { 'ticket_compliance_check': [ @@ -233,13 +306,17 @@ def test_contribution_time_cost_estimate(self): } } + contribution_row = ( + "
    ⏳ Contribution time estimate " + "(best, average, worst case): 1h | 2h | 30 minutes
    - + {contribution_row}
    ⏳ Contribution time estimate (best, average, worst case): 1h | 2h | 30 minutes
    """) assert convert_to_markdown_v2(input_data).strip() == expected_output.strip() @@ -334,7 +411,10 @@ def test_dictionary_with_empty_dictionaries(self): class TestBR: def test_br1(self): - file_change_description = '- Imported `FilePatchInfo` and `EDIT_TYPE` from `pr_agent.algo.types` instead of `pr_agent.git_providers.git_provider`.' + file_change_description = ( + "- Imported `FilePatchInfo` and `EDIT_TYPE` from `pr_agent.algo.types` " + "instead of `pr_agent.git_providers.git_provider`." + ) file_change_description_br = insert_br_after_x_chars(file_change_description) expected_output = ('
    • Imported FilePatchInfo and EDIT_TYPE from ' 'pr_agent.algo.types instead
      of ' @@ -356,11 +436,16 @@ def test_br2(self): # print(file_change_description_br) def test_br3(self): - file_change_description = 'Created a new class `ColorPaletteResourcesCollection` which extends `AvaloniaDictionary` and implements aaa' + file_change_description = ( + "Created a new class `ColorPaletteResourcesCollection` which extends " + "`AvaloniaDictionary` and implements aaa" + ) file_change_description_br = insert_br_after_x_chars(file_change_description) - assert file_change_description_br == ('Created a new class ColorPaletteResourcesCollection which ' - 'extends
      AvaloniaDictionary' - ' and implements
      aaa') + assert file_change_description_br == ( + "Created a new class ColorPaletteResourcesCollection which " + "extends
      AvaloniaDictionary" + " and implements
      aaa" + ) # print("-----") # print(file_change_description_br) diff --git a/tests/unittest/test_diff_attempt_budget.py b/tests/unittest/test_diff_attempt_budget.py new file mode 100644 index 0000000000..f1cb6ac5f1 --- /dev/null +++ b/tests/unittest/test_diff_attempt_budget.py @@ -0,0 +1,231 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +import pr_agent.algo.pr_processing as pr_processing +import pr_agent.algo.token_budget as token_budget_module +from pr_agent.algo.token_handler import TokenEncoder, TokenHandler +from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo +from pr_agent.algo.utils import ModelType +from pr_agent.config_loader import get_settings +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + + +class TaggedEncoder: + def __init__(self, model, events): + self.model = model + self.events = events + + def encode(self, text, disallowed_special=()): + self.events.append((self.model, text)) + return list(range(len(text.split()) * (2 if self.model == "fallback-model" else 1))) + + +class Provider: + def __init__(self): + self.diff_calls = 0 + + def get_diff_files(self): + self.diff_calls += 1 + return [ + FilePatchInfo("old\n", "new\n", "@@ -1 +1 @@\n-old\n+" + "new " * 80, + f"file_{index}.py", edit_type=EDIT_TYPE.MODIFIED) + for index in range(4) + ] + + def get_languages(self): + return {"Python": 100} + + +@pytest.fixture +def attempt_context(monkeypatch): + values = { + "config.model": "primary-model", + "config.model_weak": "weak-model", + "config.fallback_models": [], + "config.patch_extra_lines_before": 0, + "config.patch_extra_lines_after": 0, + "config.large_patch_policy": "skip", + "openai.deployment_id": None, + "openai.fallback_deployments": [], + } + snapshot = snapshot_settings(values) + for key, value in values.items(): + get_settings().set(key, value) + events = [] + monkeypatch.setattr(TokenEncoder, "_encoder_instance", None) + monkeypatch.setattr(TokenEncoder, "_model", None) + monkeypatch.setattr(TokenEncoder, "_create_encoder", staticmethod(lambda model: TaggedEncoder(model, events))) + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda _languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda model, **kwargs: 2_000) + handler = TokenHandler(SimpleNamespace(title="PR"), {"title": "PR"}, "System {{ title }}", "User {{ title }}") + events.clear() + yield handler, events + restore_settings(snapshot) + + +@pytest.mark.parametrize("name", ["get_pr_diff", "get_pr_diff_multiple_patchs", "get_pr_multi_diffs"]) +def test_public_packers_bind_the_attempt_before_prompt_and_diff_counting(monkeypatch, attempt_context, name): + source, events = attempt_context + windows = [] + reserves = [] + + def window(model, **kwargs): + windows.append(model) + return 2_000 + + def reserve(model, default): + reserves.append((model, default)) + return default + 100 + + monkeypatch.setattr(token_budget_module, "get_max_tokens", window) + getattr(pr_processing, name)(Provider(), source, "fallback-model", output_token_reserve=reserve) + + assert windows == ["fallback-model"] + assert reserves == [("fallback-model", 1_500)] + ( + [] if name == "get_pr_multi_diffs" else [("fallback-model", 1_000)] + ) + assert {model for model, text in events} == {"fallback-model"} + assert any(text == "System PR" for model, text in events) + assert any("+new" in text for model, text in events) + assert source.model == get_settings().config.model == "primary-model" + assert TokenEncoder._model == "primary-model" + + +@pytest.mark.parametrize("name", ["get_pr_diff", "get_pr_diff_multiple_patchs"]) +def test_compressed_budgets_keep_default_plus_reasoning_reserves(monkeypatch, attempt_context, name): + source, _ = attempt_context + seen = [] + original = pr_processing.pr_generate_compressed_diff + + def compressed(languages, handler, soft, hard, *args, **kwargs): + seen.append((handler.model, soft, hard)) + return original(languages, handler, soft, hard, *args, **kwargs) + + monkeypatch.setattr(pr_processing, "pr_generate_compressed_diff", compressed) + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda model, **kwargs: 2_400) + + getattr(pr_processing, name)( + Provider(), source, "fallback-model", output_token_reserve=lambda model, default: default + 500, + ) + + # Compare against the bound fallback prompt's eight tokens, not the primary model's four. + assert seen == [("fallback-model", 392, 892)] + + +@pytest.mark.parametrize("name", ["get_pr_diff", "get_pr_diff_multiple_patchs"]) +def test_negative_hard_capacity_stops_before_admitting_the_first_patch(attempt_context, name): + source, _ = attempt_context + + # Preserve the stricter hard stop for both valid callback values, even when + # the handler's two policies leave positive soft but negative hard room. + result = getattr(pr_processing, name)( + Provider(), source, "fallback-model", + output_token_reserve=lambda model, default: 3_000 if default == 1_000 else 1_500, + ) + + if name == "get_pr_diff": + assert result == "" + else: + assert result[0] == [[]] + assert set(result[3]) == {f"file_{index}.py" for index in range(4)} + + +@pytest.mark.parametrize("handler_form", ["source", "bound"]) +@pytest.mark.parametrize("reserve", [1_500, 1_800]) +def test_prepared_fallback_reuses_bound_counts_and_matches_fresh_packing(attempt_context, handler_form, reserve): + source, events = attempt_context + provider = Provider() + prepared = pr_processing.get_pr_diff( + provider, source, "fallback-model", add_line_numbers_to_hunks=True, return_prepared=True, + ) + assert prepared.file_dict + handler = source if handler_form == "source" else prepared.attempt_budget.token_handler + events.clear() + + chunks = pr_processing.get_pr_multi_diffs( + provider, handler, "fallback-model", prepared_diff=prepared, + output_token_reserve=lambda model, default: reserve, + ) + + assert provider.diff_calls == 1 + assert not any(text == "System PR" for model, text in events) + assert {model for model, text in events} == {"fallback-model"} + fresh = pr_processing.get_pr_multi_diffs( + Provider(), source, "fallback-model", output_token_reserve=lambda model, default: reserve, + ) + assert chunks == fresh + assert all(8 + prepared.token_handler.count_tokens(chunk) + reserve <= 2_000 for chunk in chunks) + + +@pytest.mark.parametrize("reuse", [False, True]) +def test_multi_packing_preserves_negative_capacity_and_remaining_files(monkeypatch, attempt_context, reuse): + source, _ = attempt_context + provider = Provider() + prepared = pr_processing.get_pr_diff( + provider, source, "fallback-model", add_line_numbers_to_hunks=True, return_prepared=True, + ) if reuse else None + capacities = [] + original = pr_processing._pack_pr_multi_diffs + + def pack(*args): + capacities.append(args[-1]) + return original(*args) + + monkeypatch.setattr(pr_processing, "_pack_pr_multi_diffs", pack) + chunks, remaining = pr_processing.get_pr_multi_diffs( + provider, source, "fallback-model", prepared_diff=prepared, return_remaining_files=True, + output_token_reserve=lambda model, default: 2_500, + ) + + assert capacities == [-508] + assert chunks == [] + assert set(remaining) == {f"file_{index}.py" for index in range(4)} + assert provider.diff_calls == 1 + + +@pytest.mark.parametrize("changed", ["model", "handler", "mode"]) +def test_incompatible_prepared_data_is_rebuilt(attempt_context, changed): + source, events = attempt_context + provider = Provider() + prepared = pr_processing.get_pr_diff( + provider, source, "fallback-model", add_line_numbers_to_hunks=True, return_prepared=True, + ) + handler = source.for_model("fallback-model") if changed == "handler" else source + model = "weak-model" if changed == "model" else "fallback-model" + events.clear() + + pr_processing.get_pr_multi_diffs( + provider, handler, model, prepared_diff=prepared, add_line_numbers=changed != "mode", + ) + + assert provider.diff_calls == 2 + assert {tag for tag, text in events} == {model} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("weak", [False, True]) +async def test_routed_weak_and_fallback_attempts_pack_with_their_own_tokenizer(monkeypatch, attempt_context, weak): + source, events = attempt_context + get_settings().set("config.fallback_models", ["fallback-model"]) + monkeypatch.setattr(pr_processing, "route_primary_model", lambda *args: None if weak else ("routed-model", None)) + monkeypatch.setattr(pr_processing, "record_model_used", lambda *args, **kwargs: None) + attempts = [] + + async def attempt(model): + events.clear() + pr_processing.get_pr_diff(Provider(), source, model) + attempts.append((model, {tag for tag, text in events})) + if model != "fallback-model": + raise RuntimeError("try fallback") + return "ok" + + result = await pr_processing.retry_with_fallback_models( + attempt, model_type=ModelType.WEAK if weak else ModelType.REGULAR, git_provider=MagicMock(), + ) + + primary = "weak-model" if weak else "routed-model" + assert result == "ok" + assert attempts == [(primary, {primary}), ("fallback-model", {"fallback-model"})] + assert source.model == get_settings().config.model == "primary-model" diff --git a/tests/unittest/test_diff_eof_marker_literals.py b/tests/unittest/test_diff_eof_marker_literals.py new file mode 100644 index 0000000000..4f53a830da --- /dev/null +++ b/tests/unittest/test_diff_eof_marker_literals.py @@ -0,0 +1,49 @@ +import pytest + +from pr_agent.algo.git_patch_processing import ( + decouple_and_convert_to_hunks_with_lines_numbers, + extract_hunk_lines_from_patch, +) + + +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +@pytest.mark.parametrize("section", ["", " # no newline at end of file"]) +def test_eof_marker_text_in_source_preserves_content_and_line_ranges(newline, section): + header = f"@@ -10,3 +20,3 @@{section}" + context = " # No newline at end of file" + removed = '-old("no newline at end of file")' + added = '+new("NO NEWLINE AT END OF FILE")' + patch = newline.join([header, context, removed, added, " tail()"]) + + rendered = decouple_and_convert_to_hunks_with_lines_numbers(patch, None) + + assert rendered == ( + f"\n{header}\n__new hunk__\n20 {context}\n21 {added}\n22 tail()" + f"\n__old hunk__\n{context}\n{removed}\n tail()" + ) + for side, start, changed in [("left", 10, removed), ("right", 20, f"{removed}\n{added}")]: + for offset, expected in enumerate([context, changed, " tail()"]): + full_patch, selected = extract_hunk_lines_from_patch( + patch, "example.py", start + offset, start + offset, side, + ) + assert header in full_patch + assert context in full_patch + assert removed in full_patch + assert added in full_patch + assert selected == expected + + +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_actual_eof_markers_are_skipped_but_prefixed_source_is_preserved(newline): + marker = r"\ No newline at end of file" + removed, added = f"-{marker}", f"+{marker}" + header = "@@ -1 +1 @@" + patch = newline.join([header, removed, marker, added, marker]) + + rendered = decouple_and_convert_to_hunks_with_lines_numbers(patch, None) + + assert rendered == f"\n{header}\n__new hunk__\n1 {added}\n__old hunk__\n{removed}" + for side, expected in [("left", removed), ("right", f"{removed}\n{added}")]: + full_patch, selected = extract_hunk_lines_from_patch(patch, "example.txt", 1, 1, side) + assert full_patch.splitlines()[-2:] == [removed, added] + assert selected == expected diff --git a/tests/unittest/test_diff_pipeline_core.py b/tests/unittest/test_diff_pipeline_core.py index d0ac044f46..760a5afd3e 100644 --- a/tests/unittest/test_diff_pipeline_core.py +++ b/tests/unittest/test_diff_pipeline_core.py @@ -263,8 +263,7 @@ def test_string_line_params_are_coerced_to_int(self): class TestGenerateFullPatch: - def test_files_within_budget_are_all_included(self, monkeypatch): - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 10_000) + def test_files_within_budget_are_all_included(self): token_handler = FakeTokenHandler(prompt_tokens=10) file_dict = { "a.py": {"patch": "+ change a", "tokens": 5, "edit_type": EDIT_TYPE.MODIFIED}, @@ -273,9 +272,10 @@ def test_files_within_budget_are_all_included(self, monkeypatch): total, patches, remaining, files_in = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=10_000, + soft_token_budget=10_000 - 1_500 - token_handler.prompt_tokens, remaining_files_list_prev=list(file_dict), token_handler=token_handler, + hard_token_budget=10_000 - 1_000 - token_handler.prompt_tokens, ) assert files_in == ["a.py", "b.py"] assert remaining == [] @@ -294,9 +294,10 @@ def test_oversized_patch_is_deferred_to_remaining_list(self): total, patches, remaining, files_in = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=4_000, # SOFT=1500, HARD=1000 + soft_token_budget=4_000 - 1_500 - token_handler.prompt_tokens, remaining_files_list_prev=list(file_dict), token_handler=token_handler, + hard_token_budget=4_000 - 1_000 - token_handler.prompt_tokens, ) assert "small.py" in files_in assert "huge.py" not in files_in @@ -332,9 +333,18 @@ def test_file_wrapper_is_counted_before_soft_budget_admission(self, filename): total, patches, remaining, files_in = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=max_tokens_model, + soft_token_budget=( + max_tokens_model + - pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD + - token_handler.prompt_tokens + ), remaining_files_list_prev=[filename], token_handler=token_handler, + hard_token_budget=( + max_tokens_model + - pr_processing.OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD + - token_handler.prompt_tokens + ), ) assert token_handler.prompt_tokens + file_dict[filename]["tokens"] <= ( @@ -354,9 +364,10 @@ def test_remaining_files_list_prev_filters_input(self): total, patches, remaining, files_in = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=10_000, + soft_token_budget=10_000 - 1_500 - token_handler.prompt_tokens, remaining_files_list_prev=["b.py"], # only b.py is eligible this round token_handler=token_handler, + hard_token_budget=10_000 - 1_000 - token_handler.prompt_tokens, ) assert files_in == ["b.py"] assert remaining == [] @@ -370,9 +381,10 @@ def test_line_numbered_mode_omits_extra_file_header(self): total, patches, _, _ = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=True, file_dict=file_dict, - max_tokens_model=10_000, + soft_token_budget=10_000 - 1_500 - token_handler.prompt_tokens, remaining_files_list_prev=["a.py"], token_handler=token_handler, + hard_token_budget=10_000 - 1_000 - token_handler.prompt_tokens, ) # In line-numbered mode, the function does not wrap with another header. assert patches[0].count("## File: 'a.py'") == 1 @@ -389,8 +401,7 @@ def _settings(self): from pr_agent.config_loader import get_settings return get_settings() - def test_deleted_files_collected_and_excluded_from_patches(self, monkeypatch): - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 10_000) + def test_deleted_files_collected_and_excluded_from_patches(self): deleted = FilePatchInfo( base_file="old content", @@ -408,7 +419,8 @@ def test_deleted_files_collected_and_excluded_from_patches(self, monkeypatch): pr_processing.pr_generate_compressed_diff( top_langs=top_langs, token_handler=FakeTokenHandler(prompt_tokens=10), - model="some-model", + soft_token_budget=10_000 - 1_500 - 10, + hard_token_budget=10_000 - 1_000 - 10, convert_hunks_to_line_numbers=False, large_pr_handling=False, ) @@ -422,7 +434,7 @@ def test_deleted_files_collected_and_excluded_from_patches(self, monkeypatch): assert len(patches_list) == 1 assert len(total_tokens_list) == 1 - def test_large_pr_handling_paginates_across_iterations(self, monkeypatch): + def test_large_pr_handling_paginates_across_iterations(self): # Build patches large enough that exactly one fits per iteration. The # per-iteration budget in generate_full_patch is # max_tokens_model - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - prompt_tokens @@ -439,7 +451,6 @@ def test_large_pr_handling_paginates_across_iterations(self, monkeypatch): # prompt + patch_tokens <= max - SOFT # prompt + 2 * patch_tokens > max - SOFT max_tokens = soft_threshold + prompt_tokens + patch_tokens + 1 - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: max_tokens) settings = self._settings() original_max_ai_calls = settings.pr_description.max_ai_calls @@ -459,7 +470,10 @@ def test_large_pr_handling_paginates_across_iterations(self, monkeypatch): pr_processing.pr_generate_compressed_diff( top_langs=top_langs, token_handler=token_handler, - model="some-model", + soft_token_budget=max_tokens - soft_threshold - prompt_tokens, + hard_token_budget=( + max_tokens - pr_processing.OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD - prompt_tokens + ), convert_hunks_to_line_numbers=False, large_pr_handling=True, ) @@ -473,7 +487,7 @@ def test_large_pr_handling_paginates_across_iterations(self, monkeypatch): finally: settings.pr_description.max_ai_calls = original_max_ai_calls - def test_large_pr_handling_retries_file_when_wrapper_crosses_soft_budget(self, monkeypatch): + def test_large_pr_handling_retries_file_when_wrapper_crosses_soft_budget(self): prompt_tokens = 100 token_handler = Utf8TokenHandler(prompt_tokens=prompt_tokens) patch_str = "@@ -1 +1 @@\n+" + "value " * 100 @@ -491,7 +505,6 @@ def test_large_pr_handling_retries_file_when_wrapper_crosses_soft_budget(self, m + rendered_patch_tokens + bare_patch_tokens ) - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: max_tokens) settings = self._settings() original_max_ai_calls = settings.pr_description.max_ai_calls settings.pr_description.max_ai_calls = 3 @@ -506,7 +519,12 @@ def test_large_pr_handling_retries_file_when_wrapper_crosses_soft_budget(self, m file_dict, files_in_patches_list) = pr_processing.pr_generate_compressed_diff( top_langs=[{"files": files}], token_handler=token_handler, - model="some-model", + soft_token_budget=( + max_tokens - pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - prompt_tokens + ), + hard_token_budget=( + max_tokens - pr_processing.OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD - prompt_tokens + ), convert_hunks_to_line_numbers=False, large_pr_handling=True, ) @@ -526,8 +544,7 @@ def test_large_pr_handling_retries_file_when_wrapper_crosses_soft_budget(self, m finally: settings.pr_description.max_ai_calls = original_max_ai_calls - def test_files_with_empty_patch_are_skipped(self, monkeypatch): - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 10_000) + def test_files_with_empty_patch_are_skipped(self): empty = _make_file(filename="empty.py", patch="", tokens=0) kept = _make_file(filename="kept.py", tokens=5) @@ -538,7 +555,8 @@ def test_files_with_empty_patch_are_skipped(self, monkeypatch): pr_processing.pr_generate_compressed_diff( top_langs=top_langs, token_handler=FakeTokenHandler(prompt_tokens=10), - model="some-model", + soft_token_budget=10_000 - 1_500 - 10, + hard_token_budget=10_000 - 1_000 - 10, convert_hunks_to_line_numbers=False, large_pr_handling=False, ) @@ -548,8 +566,7 @@ def test_files_with_empty_patch_are_skipped(self, monkeypatch): assert "kept.py" in file_dict assert files_in_patches_list[0] == ["kept.py"] - def test_convert_hunks_to_line_numbers_runs_decouple_per_file(self, monkeypatch): - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 10_000) + def test_convert_hunks_to_line_numbers_runs_decouple_per_file(self): kept = _make_file(filename="kept.py", tokens=5) top_langs = [{"files": [kept]}] @@ -557,7 +574,8 @@ def test_convert_hunks_to_line_numbers_runs_decouple_per_file(self, monkeypatch) pr_processing.pr_generate_compressed_diff( top_langs=top_langs, token_handler=FakeTokenHandler(prompt_tokens=10), - model="some-model", + soft_token_budget=10_000 - 1_500 - 10, + hard_token_budget=10_000 - 1_000 - 10, convert_hunks_to_line_numbers=True, large_pr_handling=False, ) @@ -565,10 +583,9 @@ def test_convert_hunks_to_line_numbers_runs_decouple_per_file(self, monkeypatch) assert "__new hunk__" in file_dict["kept.py"]["patch"] assert files_in_patches_list[0] == ["kept.py"] - def test_max_ai_calls_boundary_caps_iterations(self, monkeypatch): + def test_max_ai_calls_boundary_caps_iterations(self): # Force every patch to be too big to fit so each iteration defers # everything to the next round; this isolates the iteration cap. - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 1_000) settings = self._settings() original_max_ai_calls = settings.pr_description.max_ai_calls settings.pr_description.max_ai_calls = 2 # allow 1 extra loop iteration (range(0)) @@ -583,7 +600,8 @@ def test_max_ai_calls_boundary_caps_iterations(self, monkeypatch): pr_processing.pr_generate_compressed_diff( top_langs=top_langs, token_handler=FakeTokenHandler(prompt_tokens=10_000), - model="some-model", + soft_token_budget=1_000 - 1_500 - 10_000, + hard_token_budget=1_000 - 1_000 - 10_000, convert_hunks_to_line_numbers=False, large_pr_handling=True, ) diff --git a/tests/unittest/test_diff_remaining_files.py b/tests/unittest/test_diff_remaining_files.py new file mode 100644 index 0000000000..5d50495da1 --- /dev/null +++ b/tests/unittest/test_diff_remaining_files.py @@ -0,0 +1,53 @@ +from types import SimpleNamespace + +import pytest + +from pr_agent.algo.pr_processing import generate_full_patch + + +@pytest.mark.parametrize("line_numbers", [False, True]) +@pytest.mark.parametrize( + ("remaining_files", "expected_files"), + [ + ([], []), + (["missing.py"], []), + (["c.py", "a.py", "c.py", "empty.py", "missing.py"], ["a.py", "c.py"]), + ], +) +def test_remaining_files_preserve_file_order_and_input(line_numbers, remaining_files, expected_files): + files = {name: {"patch": "+x"} for name in ("a.py", "b.py", "c.py")} + files["empty.py"] = {"patch": ""} + original_remaining = remaining_files.copy() + counter = SimpleNamespace(prompt_tokens=7, count_tokens=len) + + total, patches, remaining, included = generate_full_patch( + line_numbers, files, 10000, remaining_files, counter, hard_token_budget=10000 + ) + + expected_patches = [ + "\n\n+x" if line_numbers else f"\n\n## File: '{name}'\n\n+x\n" + for name in expected_files + ] + assert (total, patches, remaining, included) == ( + 7 + len("\n".join(expected_patches)), expected_patches, [], expected_files + ) + assert remaining_files == original_remaining + + +@pytest.mark.parametrize("line_numbers", [False, True]) +def test_remaining_files_can_be_packed_in_successive_rounds(line_numbers): + files = {name: {"patch": "+x"} for name in ("a.py", "b.py", "c.py")} + remaining = ["c.py", "a.py", "b.py", "a.py"] + counter = SimpleNamespace(prompt_tokens=7, count_tokens=len) + budget = len("\n\n+x" if line_numbers else "\n\n## File: 'a.py'\n\n+x\n") + + for index, name in enumerate(files): + previous_remaining = remaining.copy() + total, patches, next_remaining, included = generate_full_patch( + line_numbers, files, budget, remaining, counter, hard_token_budget=budget + ) + expected_patch = "\n\n+x" if line_numbers else f"\n\n## File: '{name}'\n\n+x\n" + assert (total, patches, included) == (7 + budget, [expected_patch], [name]) + assert next_remaining == list(files)[index + 1:] + assert remaining == previous_remaining + remaining = next_remaining diff --git a/tests/unittest/test_docs_nav_parity.py b/tests/unittest/test_docs_nav_parity.py new file mode 100644 index 0000000000..33b3ee8578 --- /dev/null +++ b/tests/unittest/test_docs_nav_parity.py @@ -0,0 +1,72 @@ +"""Guards for issue #3198: keep the two documentation navigations in step. + +The docs are rendered twice. `docs/mkdocs.yml` drives the mkdocs site, and +`docs/docs/summary.md` is the GitBook table of contents, wired up in +`docs/docs/.gitbook.yaml` as `summary: ./summary.md`. `extending_pr_agent.md` +tells contributors to register every new page in both, so the two files are +expected to list exactly the same set of pages — and nothing enforces that +today. These tests do. +""" + +import re +from pathlib import Path + +DOCS = Path(__file__).resolve().parents[2] / "docs" +PAGES = DOCS / "docs" +MKDOCS = DOCS / "mkdocs.yml" +SUMMARY = PAGES / "summary.md" + +# Pages that are deliberately outside both navigations. +NOT_IN_NAV = { + # summary.md is a navigation file itself, not a page of the site. + "summary.md", +} + + +def _mkdocs_pages() -> set[str]: + """Page paths listed in the mkdocs nav, ignoring commented-out entries.""" + lines = [ + line + for line in MKDOCS.read_text(encoding="utf-8").splitlines() + if not line.lstrip().startswith("#") + ] + return set(re.findall(r"'([^']+\.md)'", "\n".join(lines))) + + +def _summary_pages() -> set[str]: + """Page paths linked from the GitBook summary.""" + return set(re.findall(r"\(([^()]+\.md)\)", SUMMARY.read_text(encoding="utf-8"))) + + +def test_every_page_is_reachable_from_a_nav(): + """No page under docs/docs is orphaned from both navigations.""" + on_disk = {p.relative_to(PAGES).as_posix() for p in PAGES.rglob("*.md")} + reachable = _mkdocs_pages() | _summary_pages() | NOT_IN_NAV + assert not on_disk - reachable, ( + "pages exist but no navigation points at them: " + f"{sorted(on_disk - reachable)}" + ) + + +def test_navs_list_the_same_pages(): + """mkdocs.yml and summary.md must not drift apart.""" + mkdocs, summary = _mkdocs_pages(), _summary_pages() + assert not mkdocs - summary, ( + f"in mkdocs.yml but missing from summary.md: {sorted(mkdocs - summary)}" + ) + assert not summary - mkdocs, ( + f"in summary.md but missing from mkdocs.yml: {sorted(summary - mkdocs)}" + ) + + +def test_navs_do_not_point_at_missing_files(): + """Every navigation entry resolves to a file that exists.""" + for label, pages in (("mkdocs.yml", _mkdocs_pages()), ("summary.md", _summary_pages())): + missing = sorted(page for page in pages if not (PAGES / page).exists()) + assert not missing, f"{label} references missing pages: {missing}" + + +def test_declared_exclusions_still_exist(): + """Keeps NOT_IN_NAV honest: a stale entry there would hide a real orphan.""" + missing = sorted(name for name in NOT_IN_NAV if not (PAGES / name).exists()) + assert not missing, f"NOT_IN_NAV lists files that no longer exist: {missing}" diff --git a/tests/unittest/test_effort_bar_clamp.py b/tests/unittest/test_effort_bar_clamp.py index b11484b3c4..a411e66ac3 100644 --- a/tests/unittest/test_effort_bar_clamp.py +++ b/tests/unittest/test_effort_bar_clamp.py @@ -40,7 +40,7 @@ def test_the_bar_is_always_five_segments(): @pytest.mark.parametrize("score, expected", [("8", 5), ("0", 1), ("3", 3)]) -def test_the_review_label_matches_the_rendered_bar(score, expected): +def test_the_review_label_matches_the_rendered_bar(score, expected, monkeypatch): """Clamp the label the same way as the bar, so the two never disagree.""" from pr_agent.config_loader import get_settings from pr_agent.tools.pr_reviewer import PRReviewer @@ -54,11 +54,11 @@ def test_the_review_label_matches_the_rendered_bar(score, expected): })() settings = get_settings(use_context=False) - settings.set("pr_reviewer.enable_review_labels_effort", True) - settings.set("pr_reviewer.enable_review_labels_security", False) - settings.set("pr_reviewer.require_estimate_effort_to_review", True) - settings.set("pr_reviewer.require_security_review", False) - settings.set("config.publish_output", True) + monkeypatch.setattr(settings.pr_reviewer, "enable_review_labels_effort", True) + monkeypatch.setattr(settings.pr_reviewer, "enable_review_labels_security", False) + monkeypatch.setattr(settings.pr_reviewer, "require_estimate_effort_to_review", True) + monkeypatch.setattr(settings.pr_reviewer, "require_security_review", False) + monkeypatch.setattr(settings.config, "publish_output", True) reviewer.set_review_labels({"review": {"estimated_effort_to_review_[1-5]": score}}) assert published and published[0] == [f"Review effort {expected}/5"] diff --git a/tests/unittest/test_extend_patch.py b/tests/unittest/test_extend_patch.py index 9e01be962b..f9798671a5 100644 --- a/tests/unittest/test_extend_patch.py +++ b/tests/unittest/test_extend_patch.py @@ -56,7 +56,7 @@ def test_single_hunk(self): original_file_str = 'line1\nline2\nline3\nline4\nline5' patch_str = '@@ -2,3 +2,3 @@ init()\n-line2\n+new_line2\n line3\n line4' - for num_lines in [1, 2, 3]: # check that even if we are over the number of lines in the file, the function still works + for num_lines in [1, 2, 3]: # still works if extra lines exceed the file expected_output = '\n@@ -1,5 +1,5 @@ init()\n line1\n-line2\n+new_line2\n line3\n line4\n line5' actual_output = extend_patch(original_file_str, patch_str, patch_extra_lines_before=num_lines, patch_extra_lines_after=num_lines) @@ -93,21 +93,35 @@ def test_dynamic_context(self): num_lines=1 get_settings(use_context=False).config.allow_dynamic_context = True - actual_output = extend_patch(original_file_str, patch_str, - patch_extra_lines_before=num_lines, patch_extra_lines_after=num_lines, new_file_str=new_file_str) - expected_output='\n@@ -1,10 +1,10 @@ \n def foo():\n line(0)\n line(1)\n line(2)\n line(3)\n line(4)\n line(5)\n line(6)\n line(7)\n- line(8)\n+ new_line(8)' + actual_output = extend_patch( + original_file_str, + patch_str, + patch_extra_lines_before=num_lines, + patch_extra_lines_after=num_lines, + new_file_str=new_file_str, + ) + expected_output = ( + "\n@@ -1,10 +1,10 @@ \n def foo():\n line(0)\n line(1)\n" + " line(2)\n line(3)\n line(4)\n line(5)\n line(6)\n" + " line(7)\n- line(8)\n+ new_line(8)" + ) assert actual_output == expected_output get_settings(use_context=False).config.allow_dynamic_context = False actual_output2 = extend_patch(original_file_str, patch_str, patch_extra_lines_before=1, patch_extra_lines_after=1) - expected_output_no_dynamic_context = '\n@@ -9,2 +9,2 @@ def foo():\n line(7)\n- line(8)\n+ new_line(8)' + expected_output_no_dynamic_context = ( + "\n@@ -9,2 +9,2 @@ def foo():\n line(7)\n- line(8)\n+ new_line(8)" + ) assert actual_output2 == expected_output_no_dynamic_context get_settings(use_context=False).config.allow_dynamic_context = False actual_output3 = extend_patch(original_file_str, patch_str, patch_extra_lines_before=3, patch_extra_lines_after=3) - expected_output_no_dynamic_context = '\n@@ -7,4 +7,4 @@ def foo():\n line(5)\n line(6)\n line(7)\n- line(8)\n+ new_line(8)' + expected_output_no_dynamic_context = ( + "\n@@ -7,4 +7,4 @@ def foo():\n line(5)\n line(6)\n line(7)\n" + "- line(8)\n+ new_line(8)" + ) assert actual_output3 == expected_output_no_dynamic_context @@ -135,15 +149,34 @@ def pr_languages(self): # Create a list of languages with files containing base_file and patch data return [ { - 'files': [ - self.File(base_file="line000\nline00\nline0\nline1\noriginal content\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10", - patch="@@ -5,5 +5,5 @@\n-original content\n+modified content\n line2\n line3\n line4\n line5", - head_file="line000\nline00\nline0\nline1\nmodified content\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10", - filename="file1"), - self.File(base_file="original content\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10", - patch="@@ -6,5 +6,5 @@\nline6\nline7\nline8\n-line9\n+modified line9\nline10", - head_file="original content\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nmodified line9\nline10", - filename="file2") + "files": [ + self.File( + base_file=( + "line000\nline00\nline0\nline1\noriginal content\n" + "line2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10" + ), + patch=( + "@@ -5,5 +5,5 @@\n-original content\n+modified content\n" + " line2\n line3\n line4\n line5" + ), + head_file=( + "line000\nline00\nline0\nline1\nmodified content\n" + "line2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10" + ), + filename="file1", + ), + self.File( + base_file=( + "original content\nline2\nline3\nline4\nline5\n" + "line6\nline7\nline8\nline9\nline10" + ), + patch="@@ -6,5 +6,5 @@\nline6\nline7\nline8\n-line9\n+modified line9\nline10", + head_file=( + "original content\nline2\nline3\nline4\nline5\n" + "line6\nline7\nline8\nmodified line9\nline10" + ), + filename="file2", + ), ] } ] @@ -168,7 +201,10 @@ def test_extend_patches_with_extra_lines(self, token_handler, pr_languages): ) p0_extended = patches_extended_with_extra_lines[0].strip() - assert p0_extended == "## File: 'file1'\n\n@@ -3,8 +3,8 @@ \n line0\n line1\n-original content\n+modified content\n line2\n line3\n line4\n line5\n line6" + assert p0_extended == ( + "## File: 'file1'\n\n@@ -3,8 +3,8 @@ \n line0\n line1\n" + "-original content\n+modified content\n line2\n line3\n line4\n line5\n line6" + ) class TestLoadLargeDiff: def test_no_newline(self): diff --git a/tests/unittest/test_fallback_model_normalization.py b/tests/unittest/test_fallback_model_normalization.py new file mode 100644 index 0000000000..a1bacba435 --- /dev/null +++ b/tests/unittest/test_fallback_model_normalization.py @@ -0,0 +1,27 @@ +from pr_agent.algo.pr_processing import _get_all_models +from pr_agent.config_loader import get_settings +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + + +def test_get_all_models_ignores_empty_comma_separated_fallbacks(): + snapshot = snapshot_settings(["config.model", "config.fallback_models"]) + settings = get_settings(use_context=False) + try: + settings.set("config.model", "primary-model") + settings.set("config.fallback_models", " fallback-a, ,fallback-b, ") + + assert _get_all_models() == ["primary-model", "fallback-a", "fallback-b"] + finally: + restore_settings(snapshot) + + +def test_get_all_models_ignores_empty_list_fallbacks(): + snapshot = snapshot_settings(["config.model", "config.fallback_models"]) + settings = get_settings(use_context=False) + try: + settings.set("config.model", "primary-model") + settings.set("config.fallback_models", ["fallback-a", "", " ", "fallback-b"]) + + assert _get_all_models() == ["primary-model", "fallback-a", "fallback-b"] + finally: + restore_settings(snapshot) diff --git a/tests/unittest/test_fetching_sub_issues.py b/tests/unittest/test_fetching_sub_issues.py index 0c48c1290c..020274533a 100644 --- a/tests/unittest/test_fetching_sub_issues.py +++ b/tests/unittest/test_fetching_sub_issues.py @@ -11,7 +11,11 @@ # class TestTicketCompliance(unittest.TestCase): # # @patch.object(GithubProvider, 'get_user_description', return_value="Fixes #1 and relates to #2") -# @patch.object(GithubProvider, '_parse_issue_url', side_effect=lambda url: ("WonOfAKind/KimchiBot", int(url.split('#')[-1]))) +# @patch.object( +# GithubProvider, +# "_parse_issue_url", +# side_effect=lambda url: ("WonOfAKind/KimchiBot", int(url.split("#")[-1])), +# ) # @patch.object(GithubProvider, 'repo_obj') # async def test_extract_tickets(self, mock_repo, mock_parse_issue_url, mock_user_desc): # """ @@ -49,7 +53,11 @@ # print("\n Test Passed: extract_tickets() successfully retrieved ticket info!") # # @patch.object(GithubProvider, 'get_user_description', return_value="Fixes #1 and relates to #2") -# @patch.object(GithubProvider, '_parse_issue_url', side_effect=lambda url: ("WonOfAKind/KimchiBot", int(url.split('#')[-1]))) +# @patch.object( +# GithubProvider, +# "_parse_issue_url", +# side_effect=lambda url: ("WonOfAKind/KimchiBot", int(url.split("#")[-1])), +# ) # @patch.object(GithubProvider, 'repo_obj') # async def test_extract_and_cache_pr_tickets(self, mock_repo, mock_parse_issue_url, mock_user_desc): # """ diff --git a/tests/unittest/test_file_filter.py b/tests/unittest/test_file_filter.py index f08841a7ed..05d27ad2d9 100644 --- a/tests/unittest/test_file_filter.py +++ b/tests/unittest/test_file_filter.py @@ -35,7 +35,22 @@ def test_glob_ignores(self, monkeypatch): ] filtered_files = filter_ignored(files) - assert filtered_files == expected, f"Expected {[file.filename for file in expected]}, but got {[file.filename for file in filtered_files]}." + assert filtered_files == expected, ( + f"Expected {[file.filename for file in expected]}, " + f"but got {[file.filename for file in filtered_files]}." + ) + + def test_glob_ignores_dict_values(self, monkeypatch): + """Verify ignore filtering for GitHub incremental dict_values views.""" + monkeypatch.setattr(global_settings.ignore, 'glob', ['*.py']) + + files = [ + type('', (object,), {'filename': 'ignored.py'})(), + type('', (object,), {'filename': 'kept.java'})(), + ] + incremental_files = {file.filename: file for file in files}.values() + + assert filter_ignored(incremental_files) == [files[1]] def test_regex_ignores(self, monkeypatch): """ @@ -56,7 +71,10 @@ def test_regex_ignores(self, monkeypatch): ] filtered_files = filter_ignored(files) - assert filtered_files == expected, f"Expected {[file.filename for file in expected]}, but got {[file.filename for file in filtered_files]}." + assert filtered_files == expected, ( + f"Expected {[file.filename for file in expected]}, " + f"but got {[file.filename for file in filtered_files]}." + ) def test_invalid_regex(self, monkeypatch): """ @@ -77,7 +95,10 @@ def test_invalid_regex(self, monkeypatch): ] filtered_files = filter_ignored(files) - assert filtered_files == expected, f"Expected {[file.filename for file in expected]}, but got {[file.filename for file in filtered_files]}." + assert filtered_files == expected, ( + f"Expected {[file.filename for file in expected]}, " + f"but got {[file.filename for file in filtered_files]}." + ) def test_language_framework_ignores(self, monkeypatch): """ @@ -128,3 +149,21 @@ def test_skip_invalid_ignore_language_framework(self, monkeypatch): f"Expected {[f.filename for f in expected]}, " f"but got {[f.filename for f in filtered]}" ) + + def test_repeated_filtering_does_not_mutate_regex_settings(self, monkeypatch): + """Ensure repeated filtering does not append translated glob patterns to shared settings.""" + configured_regex = ['^docs/'] + monkeypatch.setattr(global_settings.ignore, 'regex', configured_regex) + monkeypatch.setattr(global_settings.ignore, 'glob', ['vendor/**']) + monkeypatch.setattr(global_settings.config, 'ignore_language_framework', []) + + files = [ + type('', (object,), {'filename': 'src/app.py'})(), + type('', (object,), {'filename': 'vendor/generated.py'})(), + ] + + for _ in range(3): + filtered = filter_ignored(files) + assert filtered == [files[0]] + + assert configured_regex == ['^docs/'] diff --git a/tests/unittest/test_find_line_number_of_relevant_line_in_file.py b/tests/unittest/test_find_line_number_of_relevant_line_in_file.py index 06db14f08d..5a7c6a1000 100644 --- a/tests/unittest/test_find_line_number_of_relevant_line_in_file.py +++ b/tests/unittest/test_find_line_number_of_relevant_line_in_file.py @@ -5,60 +5,91 @@ class TestFindLineNumberOfRelevantLineInFile: - # Tests that the function returns the correct line number and absolute position when the relevant line is found in the patch + # Returns the correct line number and absolute position when the relevant line is in the patch def test_relevant_line_found_in_patch(self): diff_files = [ - FilePatchInfo(base_file='file1', head_file='file1', patch='@@ -1,1 +1,2 @@\n-line1\n+line2\n+relevant_line\n', filename='file1') + FilePatchInfo( + base_file="file1", + head_file="file1", + patch="@@ -1,1 +1,2 @@\n-line1\n+line2\n+relevant_line\n", + filename="file1", + ) ] relevant_file = 'file1' relevant_line_in_file = 'relevant_line' expected = (3, 2) # (position in patch, absolute_position in new file) assert find_line_number_of_relevant_line_in_file(diff_files, relevant_file, relevant_line_in_file) == expected - # Tests that the function returns the correct line number and absolute position when a similar line is found using difflib + # Returns the correct line number when a similar line is found using difflib def test_similar_line_found_using_difflib(self): diff_files = [ - FilePatchInfo(base_file='file1', head_file='file1', patch='@@ -1,1 +1,2 @@\n-line1\n+relevant_line in file similar match\n', filename='file1') + FilePatchInfo( + base_file="file1", + head_file="file1", + patch="@@ -1,1 +1,2 @@\n-line1\n+relevant_line in file similar match\n", + filename="file1", + ) ] relevant_file = 'file1' - relevant_line_in_file = '+relevant_line in file similar match ' # note the space at the end. This is to simulate a similar line found using difflib + # trailing space simulates a similar line found using difflib + relevant_line_in_file = '+relevant_line in file similar match ' expected = (2, 1) assert find_line_number_of_relevant_line_in_file(diff_files, relevant_file, relevant_line_in_file) == expected - # Tests that the function returns (-1, -1) when the relevant line is not found in the patch and no similar line is found using difflib + # Returns (-1, -1) when the relevant line is not in the patch and no similar line is found def test_relevant_line_not_found(self): diff_files = [ - FilePatchInfo(base_file='file1', head_file='file1', patch='@@ -1,1 +1,2 @@\n-line1\n+relevant_line\n', filename='file1') + FilePatchInfo( + base_file="file1", + head_file="file1", + patch="@@ -1,1 +1,2 @@\n-line1\n+relevant_line\n", + filename="file1", + ) ] relevant_file = 'file1' relevant_line_in_file = 'not_found' expected = (-1, -1) assert find_line_number_of_relevant_line_in_file(diff_files, relevant_file, relevant_line_in_file) == expected - # Tests that the function returns (-1, -1) when the relevant file is not found in any of the patches + # Returns (-1, -1) when the relevant file is not found in any of the patches def test_relevant_file_not_found(self): diff_files = [ - FilePatchInfo(base_file='file1', head_file='file1', patch='@@ -1,1 +1,2 @@\n-line1\n+relevant_line\n', filename='file2') + FilePatchInfo( + base_file="file1", + head_file="file1", + patch="@@ -1,1 +1,2 @@\n-line1\n+relevant_line\n", + filename="file2", + ) ] relevant_file = 'file1' relevant_line_in_file = 'relevant_line' expected = (-1, -1) assert find_line_number_of_relevant_line_in_file(diff_files, relevant_file, relevant_line_in_file) == expected - # Tests that the function returns (-1, -1) when the relevant_line_in_file is an empty string + # Returns (-1, -1) when the relevant_line_in_file is an empty string def test_empty_relevant_line(self): diff_files = [ - FilePatchInfo(base_file='file1', head_file='file1', patch='@@ -1,1 +1,2 @@\n-line1\n+relevant_line\n', filename='file1') + FilePatchInfo( + base_file="file1", + head_file="file1", + patch="@@ -1,1 +1,2 @@\n-line1\n+relevant_line\n", + filename="file1", + ) ] relevant_file = 'file1' relevant_line_in_file = '' expected = (-1, -1) assert find_line_number_of_relevant_line_in_file(diff_files, relevant_file, relevant_line_in_file) == expected - # Tests that the function returns (-1, -1) when the relevant_line_in_file is found in the patch but it is a deleted line + # Returns (-1, -1) when the relevant line is found in the patch but deleted def test_relevant_line_found_but_deleted(self): diff_files = [ - FilePatchInfo(base_file='file1', head_file='file1', patch='@@ -1,2 +1,1 @@\n-line1\n-relevant_line\n', filename='file1') + FilePatchInfo( + base_file="file1", + head_file="file1", + patch="@@ -1,2 +1,1 @@\n-line1\n-relevant_line\n", + filename="file1", + ) ] relevant_file = 'file1' relevant_line_in_file = 'relevant_line' @@ -90,3 +121,54 @@ def test_hunk_header_with_omitted_count_on_the_new_side_only(self): assert position == 1 assert absolute_position == 1 + + def test_combined_diff_header_does_not_crash(self): + """Skip a combined/merge hunk header (@@@) instead of crashing on its None regex match.""" + diff_files = [ + FilePatchInfo(base_file="file1", head_file="file1", + patch="@@@ -1,2 -1,2 +1,4 @@@\n- a\n +b\n c\n +d", filename="file1") + ] + + position, absolute_position = find_line_number_of_relevant_line_in_file( + diff_files, "file1", "c") + + assert position == -1 + assert absolute_position == -1 + + def test_combined_diff_header_absolute_position_does_not_crash(self): + diff_files = [ + FilePatchInfo(base_file="file1", head_file="file1", + patch="@@@ -1,2 -1,2 +1,4 @@@\n- a\n +b\n c\n +d", filename="file1") + ] + + position, absolute_position = find_line_number_of_relevant_line_in_file( + diff_files, "file1", "not_found", absolute_position=3) + + assert position == -1 + + def test_combined_diff_header_plus_line_does_not_crash(self): + diff_files = [ + FilePatchInfo(base_file="file1", head_file="file1", + patch="@@@ -1,2 -1,2 +1,4 @@@\n- a\n +b\n c\n +d", filename="file1") + ] + + position, absolute_position = find_line_number_of_relevant_line_in_file( + diff_files, "file1", "+b") + + assert position == -1 + assert absolute_position == -1 + + def test_a_valid_hunk_after_a_combined_header_is_still_found(self): + """Keep anchoring from a valid hunk rendered after a skipped combined header.""" + combined = "@@@ -1,2 -1,2 +1,4 @@@\n- a\n +b\n c\n +d" + valid_hunk = "@@ -3,1 +5,1 @@\n ctx\n-old\n+new" + diff_files = [ + FilePatchInfo(base_file="file1", head_file="file1", + patch=combined + "\n" + valid_hunk, filename="file1") + ] + + position, absolute_position = find_line_number_of_relevant_line_in_file( + diff_files, "file1", "new") + + assert position == 8 + assert absolute_position == 6 diff --git a/tests/unittest/test_generate_labels_entry_shape.py b/tests/unittest/test_generate_labels_entry_shape.py index dda9d10f86..54da528949 100644 --- a/tests/unittest/test_generate_labels_entry_shape.py +++ b/tests/unittest/test_generate_labels_entry_shape.py @@ -1,7 +1,47 @@ -"""Read the labels list whatever entry shape the model returns.""" +"""Validate and read the labels list inside each model attempt.""" +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, call, patch + import pytest +from pr_agent.algo.pr_processing import retry_with_fallback_models +from pr_agent.config_loader import get_settings from pr_agent.tools.pr_generate_labels import PRGenerateLabels +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +_TRACKED_KEYS = ( + "config.model", + "config.fallback_models", + "config.publish_output", + "config.propagate_tool_errors", + "config.enable_custom_labels", + "config.custom_model_max_tokens", + "custom_labels", + "model_routing.enable", + "openai.deployment_id", + "openai.fallback_deployments", +) + +_VALID_FALLBACK = "labels:\n- bug fix\n" + + +@pytest.fixture +def fallback_models(): + snapshot = snapshot_settings(_TRACKED_KEYS) + settings = get_settings() + settings.set("config.model", "primary-model") + settings.set("config.fallback_models", ["fallback-model"]) + settings.set("config.publish_output", True) + settings.set("config.propagate_tool_errors", False) + settings.set("config.enable_custom_labels", False) + settings.set("config.custom_model_max_tokens", 10_000) + settings.set("custom_labels", []) + settings.set("model_routing.enable", False) + settings.set("openai.deployment_id", None) + settings.set("openai.fallback_deployments", []) + yield + restore_settings(snapshot) def labels(prediction, variables=None): @@ -13,6 +53,30 @@ def labels(prediction, variables=None): return tool._prepare_labels() +def label_tool(git_provider=None): + tool = PRGenerateLabels.__new__(PRGenerateLabels) + tool.git_provider = git_provider or MagicMock() + tool.token_handler = MagicMock() + tool.pr_id = "repo#1" + tool.patches_diff = None + tool.prediction = None + tool.data = None + tool.variables = {} + tool.ai_handler = SimpleNamespace() + tool.vars = { + "title": "Title", + "branch": "feature", + "description": "Description", + "language": "Python", + "diff": "", + "extra_instructions": "", + "commit_messages_str": "", + "enable_custom_labels": False, + "custom_labels_class": "", + } + return tool + + def test_read_the_documented_list_of_strings(): """Keep the documented shape working exactly as before.""" assert labels("labels:\n- bug fix\n- tests\n") == ["bug fix", "tests"] @@ -38,6 +102,191 @@ def test_drop_an_entry_that_carries_no_name(): assert labels("labels:\n- {}\n- bug fix\n") == ["bug fix"] -def test_no_labels_key_returns_nothing(): - """Keep returning an empty list when the model produced no labels.""" - assert labels("types:\n- bug fix\n") == [] +def test_keep_custom_label_case_mapping(): + assert labels( + "labels:\n- bug_fix\n", + {"labels_minimal_to_labels_dict": {"bug_fix": "Bug Fix"}}, + ) == ["Bug Fix"] + + +def test_no_labels_key_is_not_a_successful_attempt(): + with pytest.raises(ValueError, match="labels"): + labels("types:\n- bug fix\n") + + +@pytest.mark.asyncio +async def test_malformed_primary_uses_valid_fallback_and_preserves_user_labels(fallback_models): + provider = MagicMock() + provider.publish_comment.return_value = MagicMock() + provider.is_supported.return_value = True + provider.get_pr_labels.return_value = ["keep-me"] + tool = label_tool(provider) + tool._get_prediction = AsyncMock(side_effect=["not valid labels yaml", _VALID_FALLBACK]) + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await tool.run() + + assert tool._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + provider.publish_labels.assert_called_once_with(["bug fix", "keep-me"]) + provider.publish_comment.assert_called_once_with("Preparing PR labels...", is_temporary=True) + provider.remove_initial_comment.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_explicit_empty_labels_is_valid_and_keeps_publication_path(fallback_models): + provider = MagicMock() + provider.publish_comment.return_value = MagicMock() + provider.is_supported.return_value = True + provider.get_pr_labels.return_value = ["keep-me"] + tool = label_tool(provider) + tool._get_prediction = AsyncMock(return_value="labels: []\n") + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await tool.run() + + tool._get_prediction.assert_awaited_once_with("primary-model") + provider.publish_labels.assert_called_once_with(["keep-me"]) + provider.remove_initial_comment.assert_called_once_with() + + +@pytest.mark.parametrize( + "invalid_prediction", + [ + "", + "not valid labels yaml", + "- bug fix\n", + "types:\n- bug fix\n", + "labels:\n name: bug fix\n", + "labels:\n", + "labels: 7\n", + "labels: false\n", + "labels: ' , '\n", + "labels:\n- {}\n- false\n", + ], +) +@pytest.mark.asyncio +async def test_invalid_attempt_shapes_reach_fallback(invalid_prediction, fallback_models): + tool = label_tool() + tool._get_prediction = AsyncMock(side_effect=[invalid_prediction, _VALID_FALLBACK]) + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await retry_with_fallback_models(tool._prepare_prediction, git_provider=tool.git_provider) + + assert tool._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + assert tool.prediction == _VALID_FALLBACK + assert tool._prepare_labels() == ["bug fix"] + + +@pytest.mark.asyncio +async def test_usable_entries_survive_malformed_siblings_without_fallback(fallback_models): + tool = label_tool() + tool._get_prediction = AsyncMock(return_value="labels:\n- {}\n- bug fix\n- false\n") + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await retry_with_fallback_models(tool._prepare_prediction, git_provider=tool.git_provider) + + tool._get_prediction.assert_awaited_once_with("primary-model") + assert tool._prepare_labels() == ["bug fix"] + + +@pytest.mark.asyncio +async def test_all_invalid_attempts_clear_state_and_cleanup_progress_once(fallback_models): + provider = MagicMock() + provider.publish_comment.return_value = MagicMock() + provider.is_supported.return_value = True + tool = label_tool(provider) + tool._get_prediction = AsyncMock( + side_effect=["types:\n- bug fix\n", "labels:\n- {}\n- false\n"] + ) + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await tool.run() + + assert tool._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + assert tool.prediction is None + assert tool.data is None + provider.publish_labels.assert_not_called() + provider.publish_comment.assert_called_once_with("Preparing PR labels...", is_temporary=True) + provider.remove_initial_comment.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_all_invalid_attempts_do_not_cleanup_without_progress_handle(fallback_models): + provider = MagicMock() + provider.publish_comment.return_value = None + provider.is_supported.return_value = True + tool = label_tool(provider) + tool._get_prediction = AsyncMock( + side_effect=["types:\n- bug fix\n", "labels:\n- {}\n- false\n"] + ) + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await tool.run() + + assert tool._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + assert tool.prediction is None + assert tool.data is None + provider.publish_labels.assert_not_called() + provider.publish_comment.assert_called_once_with("Preparing PR labels...", is_temporary=True) + provider.remove_initial_comment.assert_not_called() + provider.remove_comment.assert_not_called() + + +@pytest.mark.asyncio +async def test_valid_attempt_does_not_cleanup_without_progress_handle(fallback_models): + provider = MagicMock() + provider.publish_comment.return_value = None + provider.is_supported.return_value = True + provider.get_pr_labels.return_value = [] + tool = label_tool(provider) + tool._get_prediction = AsyncMock(return_value=_VALID_FALLBACK) + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + await tool.run() + + tool._get_prediction.assert_awaited_once_with("primary-model") + provider.publish_labels.assert_called_once_with(["bug fix"]) + provider.publish_comment.assert_called_once_with("Preparing PR labels...", is_temporary=True) + provider.remove_initial_comment.assert_not_called() + provider.remove_comment.assert_not_called() + + +@pytest.mark.parametrize("propagate_tool_errors", [False, True]) +@pytest.mark.asyncio +async def test_cleanup_failure_is_best_effort_after_success( + fallback_models, propagate_tool_errors +): + provider = MagicMock() + provider.publish_comment.return_value = MagicMock() + provider.is_supported.return_value = True + provider.get_pr_labels.return_value = [] + provider.remove_initial_comment.side_effect = RuntimeError("cleanup failed") + tool = label_tool(provider) + tool._get_prediction = AsyncMock(return_value=_VALID_FALLBACK) + get_settings().set("config.propagate_tool_errors", propagate_tool_errors) + + with patch("pr_agent.tools.pr_generate_labels.get_pr_diff", return_value="diff"): + assert await tool.run() == "" + + provider.publish_labels.assert_called_once_with(["bug fix"]) + provider.remove_initial_comment.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_cancellation_after_progress_handle_cleans_up_before_propagating(fallback_models): + provider = MagicMock() + provider.publish_comment.return_value = MagicMock() + tool = label_tool(provider) + + with ( + patch( + "pr_agent.tools.pr_generate_labels.retry_with_fallback_models", + new=AsyncMock(side_effect=asyncio.CancelledError()), + ), + pytest.raises(asyncio.CancelledError), + ): + await tool.run() + + provider.publish_comment.assert_called_once_with("Preparing PR labels...", is_temporary=True) + provider.remove_initial_comment.assert_called_once_with() + provider.publish_labels.assert_not_called() diff --git a/tests/unittest/test_get_max_tokens.py b/tests/unittest/test_get_max_tokens.py index 7525fc3bc7..01b35f41c7 100644 --- a/tests/unittest/test_get_max_tokens.py +++ b/tests/unittest/test_get_max_tokens.py @@ -1,15 +1,27 @@ import litellm import pytest -import pr_agent.algo.utils as utils +import pr_agent.algo.token_budget as token_budget from pr_agent.algo import ( _CLAUDE_MODEL_FAMILIES, CLAUDE_EXTENDED_THINKING_MODELS, + MAX_TOKENS, NO_SUPPORT_TEMPERATURE_MODELS, _generate_claude_registries, _validate_claude_model_family, ) -from pr_agent.algo.utils import MAX_TOKENS, get_max_tokens +from pr_agent.algo.token_budget import get_max_tokens + + +def _expected_max_tokens(model: str) -> int: + """Resolve a model's expected max tokens the same way get_max_tokens() does: + prefer the static MAX_TOKENS registry, else fall back to LiteLLM (issue #3196). + Used so tests keep working for models that were intentionally removed from + MAX_TOKENS because LiteLLM's fallback already resolves them correctly. + """ + if model in MAX_TOKENS: + return MAX_TOKENS[model] + return int(litellm.get_model_info(model)["max_input_tokens"]) class TestGetMaxTokens: @@ -23,7 +35,7 @@ def test_model_max_tokens(self, monkeypatch): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) model = "gpt-3.5-turbo" expected = MAX_TOKENS[model] @@ -39,7 +51,7 @@ def test_gpt54_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 272000 @@ -52,7 +64,7 @@ def test_gpt54_mini_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 400000 @@ -65,7 +77,7 @@ def test_gpt54_nano_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 400000 @@ -78,7 +90,7 @@ def test_gpt55_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 1050000 @@ -91,7 +103,7 @@ def test_gpt56_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 1050000 @@ -105,7 +117,7 @@ def test_gpt6_astra_model_max_tokens(self, monkeypatch, prefix, suffix, cap): "max_model_tokens": cap, })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) monkeypatch.setattr(litellm, "get_model_info", lambda *args, **kwargs: pytest.fail("Static lookup expected")) assert get_max_tokens(f"{prefix}gpt-6-astra{suffix}") == (cap or 1050000) @@ -122,7 +134,7 @@ def test_gpt6_astra_alias_preserves_custom_limit(self, monkeypatch, model, cap, "max_model_tokens": cap, })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == expected @@ -142,8 +154,8 @@ def test_gpt5_thinking_model_max_tokens(self, monkeypatch, model): "max_model_tokens": 0 })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) - expected = MAX_TOKENS[model.removesuffix("_thinking")] + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) + expected = _expected_max_tokens(model.removesuffix("_thinking")) assert get_max_tokens(model) == expected @pytest.mark.parametrize("model", [ @@ -158,7 +170,7 @@ def test_gpt5_thinking_alias_preserves_custom_limit(self, monkeypatch, model): "max_model_tokens": 0 })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 7000 @@ -173,7 +185,7 @@ def test_gpt5_thinking_alias_respects_max_model_tokens_cap(self, monkeypatch, cu "max_model_tokens": 7000 })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens("openai/gpt-5.6_thinking") == 7000 @@ -194,13 +206,13 @@ def test_gpt5_thinking_prefixed_model_max_tokens(self, monkeypatch, model): "max_model_tokens": 0 })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) # Strip provider prefixes then _thinking suffix to get base key tmp = model while tmp.startswith(("openai/", "azure/")): tmp = tmp.removeprefix("openai/").removeprefix("azure/") base = tmp.removesuffix("_thinking") - expected = MAX_TOKENS[base] + expected = _expected_max_tokens(base) assert get_max_tokens(model) == expected def test_non_gpt5_thinking_model_max_tokens_not_stripped(self, monkeypatch): @@ -210,7 +222,7 @@ def test_non_gpt5_thinking_model_max_tokens_not_stripped(self, monkeypatch): "max_model_tokens": 0 })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) with pytest.raises(Exception): get_max_tokens("gpt-4o_thinking") @@ -222,7 +234,7 @@ def test_thinking_fallback_skips_invalid_token_metadata(self, monkeypatch, inval "config": type("Config", (), {"custom_model_max_tokens": 0, "max_model_tokens": 32000})(), "get": lambda self, key, default=None: default, })() - monkeypatch.setattr(utils, "get_settings", lambda: settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: settings) model = "openai/gpt-5.9_thinking" lookups = [] @@ -307,7 +319,7 @@ def test_gpt5_thinking_litellm_fallback( })(), "get": lambda self, key, default=None: setting_values.get(key, default), })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) lookups = [] def mock_get_model_info(m): @@ -337,7 +349,7 @@ def test_openrouter_router_model_max_tokens(self, monkeypatch, model, expected): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == expected @@ -350,7 +362,7 @@ def test_model_has_custom(self, monkeypatch): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) model = "custom-model" expected = 5000 @@ -370,9 +382,9 @@ def test_gpt_codex_models_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) - expected = MAX_TOKENS[model] + expected = _expected_max_tokens(model) assert get_max_tokens(model) == expected @@ -384,7 +396,7 @@ def test_model_not_max_tokens_and_not_has_custom(self, monkeypatch): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) model = "custom-model" @@ -399,7 +411,7 @@ def test_model_max_tokens_with__limit(self, monkeypatch): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) model = "gpt-3.5-turbo" # this model setting is 160000 expected = 10000 @@ -417,6 +429,8 @@ def test_model_max_tokens_with__limit(self, monkeypatch): "vertex_ai/gemini-3.1-flash", "gemini/gemini-3.1-pro", "vertex_ai/gemini-3.1-pro", + "gemini/gemini-3.1-flash-lite", + "vertex_ai/gemini-3.1-flash-lite", "gemini/gemini-3.1-flash-lite-preview", "vertex_ai/gemini-3.1-flash-lite-preview", "gemini/gemini-3.5-flash", @@ -439,7 +453,7 @@ def test_gemini_3_x_models_max_tokens(self, monkeypatch, model): "max_model_tokens": 0, })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 1048576 def test_bedrock_mantle_grok_4_3_model_max_tokens(self, monkeypatch): @@ -450,7 +464,7 @@ def test_bedrock_mantle_grok_4_3_model_max_tokens(self, monkeypatch): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens("bedrock_mantle/xai.grok-4.3") == 1000000 @@ -470,7 +484,7 @@ def test_xai_and_openrouter_grok_4_5_and_4_6_models_max_tokens(self, monkeypatch })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 500000 @@ -496,7 +510,7 @@ def test_claude_opus_4_8_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 1000000 @@ -522,7 +536,7 @@ def test_claude_opus_5_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 1000000 @@ -545,7 +559,7 @@ def test_claude_opus_4_7_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 1000000 @@ -568,7 +582,7 @@ def test_claude_opus_4_6_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 200000 @@ -594,7 +608,7 @@ def test_claude_sonnet_5_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 1000000 @@ -617,7 +631,7 @@ def test_claude_fable_5_1_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 1000000 @@ -643,7 +657,7 @@ def test_claude_sonnet_4_6_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 200000 @@ -661,9 +675,10 @@ def test_zai_glm_5_2_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) - assert get_max_tokens(model) == 200000 + # 1M per LiteLLM (issue #3196): zai/glm-5.2 was understated at 200000. + assert get_max_tokens(model) == 1000000 @pytest.mark.parametrize( "model", @@ -679,9 +694,10 @@ def test_moonshot_kimi_k3_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) - assert get_max_tokens(model) == 262144 + # 1M per LiteLLM (issue #3196): moonshot/kimi-k3 was understated at 262144. + assert get_max_tokens(model) == 1048576 @pytest.mark.parametrize( "model", @@ -697,7 +713,7 @@ def test_qwen_3_8_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 1000000 @@ -717,7 +733,7 @@ def test_xiaomi_mimo_v2_5_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 1048576 @@ -735,7 +751,7 @@ def test_xiaomi_mimo_v2_5_pro_model_max_tokens(self, monkeypatch, model): })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == 1048576 @@ -749,7 +765,7 @@ def test_max_tokens_takes_precedence_over_litellm(self, monkeypatch): 'max_model_tokens': 0 })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) def fail_if_called(*args, **kwargs): raise AssertionError("litellm.get_model_info should not be called") @@ -767,7 +783,7 @@ def test_custom_model_max_tokens_takes_precedence_over_litellm(self, monkeypatch 'max_model_tokens': 0 })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) def fail_if_called(*args, **kwargs): raise AssertionError("litellm.get_model_info should not be called") @@ -784,7 +800,7 @@ def test_litellm_fallback_returns_max_input_tokens(self, monkeypatch): 'max_model_tokens': 0 })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) monkeypatch.setattr(litellm, "get_model_info", lambda model: {"max_input_tokens": 65536}) @@ -798,7 +814,7 @@ def test_litellm_fallback_uses_max_input_tokens_not_max_tokens(self, monkeypatch 'max_model_tokens': 0 })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) monkeypatch.setattr(litellm, "get_model_info", lambda model: { "max_input_tokens": 32000, "max_tokens": 99999, @@ -814,7 +830,7 @@ def test_litellm_fallback_failure_preserves_existing_error(self, monkeypatch): 'max_model_tokens': 0 })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) def raise_for_unknown(model): raise Exception("This model isn't mapped yet") @@ -832,7 +848,7 @@ def test_litellm_fallback_respects_max_model_tokens_cap(self, monkeypatch): 'max_model_tokens': 8000 })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) monkeypatch.setattr(litellm, "get_model_info", lambda model: {"max_input_tokens": 65536}) @@ -856,7 +872,7 @@ def test_litellm_fallback_uses_real_registry(self, monkeypatch): "max_model_tokens": 0, })() })() - monkeypatch.setattr(utils, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) assert get_max_tokens(model) == expected_max_input_tokens @@ -950,7 +966,7 @@ def test_claude_model_family_metadata_key_validation(self): Proves: - all current shipped families validate successfully. - an unknown top-level key (e.g. 'bedrock_region') raises ValueError naming the key and model_id. - - an unknown nested extra_aliases key (e.g. 'extended_thinkin') raises ValueError naming the key, alias, and model_id. + - an unknown nested extra_aliases key (e.g. 'extended_thinkin') raises ValueError with key, alias, and model_id. - global family definitions are not mutated. """ # All current shipped families must pass validation @@ -1058,3 +1074,120 @@ def test_claude_registries_baseline_parity_and_no_duplicates(self): claude_thinking = {m for m in CLAUDE_EXTENDED_THINKING_MODELS if "claude" in m} claude_no_temp = {m for m in NO_SUPPORT_TEMPERATURE_MODELS if "claude" in m} assert claude_thinking.isdisjoint(claude_no_temp) + + @pytest.mark.parametrize( + "model, expected", + [ + ("gpt-4o", 128000), + ("gpt-4.1", 1047576), + ], + ) + def test_ignore_max_model_tokens_returns_unreduced_litellm_value(self, monkeypatch, model, expected): + """Sites that bypass the max_model_tokens clamp (pr_help_message, pr_help_docs, + pr_code_suggestions) must still get the raw model context even after the exact + LiteLLM duplicates left the registry: the value now resolves from LiteLLM, while + the default path keeps clamping to config.max_model_tokens.""" + fake_settings = type('', (), { + 'config': type('', (), { + 'custom_model_max_tokens': 0, + 'max_model_tokens': 32000 + })() + })() + + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) + monkeypatch.setattr(token_budget, "MAX_TOKENS", {}) # simulate deletion of the entry + + assert get_max_tokens(model) == 32000 + assert get_max_tokens(model, ignore_max_model_tokens=True) == expected + + +class TestNoLiteLLMDuplicates: + + # Models pinned in MAX_TOKENS because LiteLLM's bundled backup cost map (used + # when the import-time fetch fails, and under LITELLM_LOCAL_MODEL_COST_MAP=true) + # does not carry them, so the get_max_tokens() fallback cannot resolve them. + # They are exempt from the no-duplicates guard by design. + LITELLM_BUNDLED_MAP_UNKNOWN = { + "gemini/gemini-3.8-flash", + "vertex_ai/gemini-3.8-flash", + "openrouter/x-ai/grok-4.5", + "openrouter/x-ai/grok-4.6", + "xai/grok-build-latest", + "zai/glm-5.2", + } + + # The models issue #3196 found pinned more than 10% below LiteLLM's reported + # context window. zai/glm-5.2 is covered by test_zai_glm_5_2_model_max_tokens + # (kept pinned, absent from the bundled cost map); gpt-5.4 / gpt-5.4-2026-03-05 + # are excluded because their 272000 pin is a documented safe default. + AUDITED_UNDERSTATED_MODELS = [ + "mistral/mistral-medium-latest", + "mistral/mistral-small-latest", + "mistral/codestral-latest", + "mistral/open-mixtral-8x22b", + "mistral/mistral-large-latest", + "mistral/open-mistral-7b", + "mistral/open-mixtral-8x7b", + "codestral/codestral-latest", + "codestral/codestral-2405", + "watsonx/mistralai/mistral-large", + "moonshot/kimi-k3", + "deepseek/deepseek-reasoner", + "deepinfra/deepseek-ai/DeepSeek-R1", + "gpt-5", + "gpt-5-2025-08-07", + "gpt-5-nano", + "gpt-5-mini", + "gpt-5.1", + "gpt-5.1-2025-11-13", + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "ollama/llama3", + ] + + @pytest.mark.parametrize("model", AUDITED_UNDERSTATED_MODELS) + def test_previously_understated_models_now_resolve_correctly(self, monkeypatch, model): + """Regression guard for issue #3196. + + A static MAX_TOKENS entry takes precedence over the LiteLLM fallback, so a + pin below LiteLLM's max_input_tokens silently shrinks the usable context. + Compare against LiteLLM live rather than a copied number, so a LiteLLM + upgrade that raises a window does not fail this test. + """ + fake_settings = type("", (), { + "config": type("", (), { + "custom_model_max_tokens": 0, + "max_model_tokens": 0 + })() + })() + monkeypatch.setattr(token_budget, "get_settings", lambda: fake_settings) + + litellm_max = int(litellm.get_model_info(model)["max_input_tokens"]) + assert get_max_tokens(model) >= litellm_max + + def test_static_max_tokens_has_no_exact_litellm_duplicates(self): + """Hardcoded MAX_TOKENS entries must not just mirror LiteLLM. + + get_max_tokens() already falls back to litellm.get_model_info(), so a + static entry that reports the identical value is dead duplication. + Generator-expanded Claude families are excluded: they also drive the + no-temperature / extended-thinking registries, and their 1M-context + handling is a separate, deliberate judgement (issue #3196). Entries in + LITELLM_BUNDLED_MAP_UNKNOWN are pinned because the bundled cost map does + not carry them, so the fallback could not resolve them. + """ + generated = set(_generate_claude_registries()[0]) + static = { + k: v + for k, v in MAX_TOKENS.items() + if k not in generated and k not in self.LITELLM_BUNDLED_MAP_UNKNOWN + } + dups = [] + for model, ours in static.items(): + try: + theirs = int(litellm.get_model_info(model).get("max_input_tokens")) + except Exception: + continue + if theirs == ours: + dups.append((model, ours)) + assert not dups, f"MAX_TOKENS entries that exactly duplicate LiteLLM: {sorted(dups)}" diff --git a/tests/unittest/test_get_version.py b/tests/unittest/test_get_version.py index f3ae2202da..443619562b 100644 --- a/tests/unittest/test_get_version.py +++ b/tests/unittest/test_get_version.py @@ -1,8 +1,8 @@ """get_version() must not trust a pyproject.toml that belongs to a different project.""" import pytest -from pr_agent.algo import utils -from pr_agent.algo.utils import get_version +from pr_agent.algo import run_output +from pr_agent.algo.run_output import get_version INSTALLED_VERSION = "9.44.0-from-package-metadata" @@ -10,7 +10,7 @@ @pytest.fixture def installed_version(monkeypatch): """Make the package-metadata fallback return a recognizable sentinel.""" - monkeypatch.setattr(utils, "version", lambda name: INSTALLED_VERSION) + monkeypatch.setattr(run_output, "version", lambda name: INSTALLED_VERSION) return INSTALLED_VERSION diff --git a/tests/unittest/test_git_provider_head_sha.py b/tests/unittest/test_git_provider_head_sha.py new file mode 100644 index 0000000000..e162adf1ca --- /dev/null +++ b/tests/unittest/test_git_provider_head_sha.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the provider-level PR head SHA contract. + +`persistent_finding_state` resolves a previously reported finding when the head moved +since the last full review. The reviewer used to read a GitHub-shaped `last_commit_id` +attribute directly, so providers that never set it recorded an empty head and could +never resolve anything. Each provider now resolves its own shape behind +`get_pr_head_sha()`, which the reviewer calls. +""" + +from types import SimpleNamespace + +from pr_agent.git_providers.azuredevops_provider import AzureDevopsProvider +from pr_agent.git_providers.bitbucket_provider import BitbucketProvider +from pr_agent.git_providers.bitbucket_server_provider import BitbucketServerProvider +from pr_agent.git_providers.git_provider import GitProvider +from pr_agent.git_providers.gitea_provider import GiteaProvider +from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.git_providers.gitlab_provider import GitLabProvider +from pr_agent.tools.pr_reviewer import PRReviewer + +HEAD_SHA = "a" * 40 + +# (name, provider class, attribute holding its PR object, the object shape it exposes) +PROVIDER_SHAPES = ( + ("gitlab", GitLabProvider, "mr", SimpleNamespace(diff_refs={"head_sha": HEAD_SHA})), + ("bitbucket", BitbucketProvider, "pr", SimpleNamespace(data={"source": {"commit": {"hash": HEAD_SHA}}})), + ("bitbucket-server", BitbucketServerProvider, "pr", SimpleNamespace(fromRef={"latestCommit": HEAD_SHA})), + ("github", GithubProvider, "pr", SimpleNamespace(head=SimpleNamespace(sha=HEAD_SHA))), + ("gitea", GiteaProvider, "last_commit", SimpleNamespace(sha=HEAD_SHA)), + # Azure is the provider #3431 was filed against. It records the source + # revision on `last_merge_commit`, a different field from every other + # provider here, so it has its own row. + ("azure", AzureDevopsProvider, "pr", SimpleNamespace(last_merge_commit=SimpleNamespace(commit_id=HEAD_SHA))), +) + + +def _provider(cls, attribute, shape): + provider = cls.__new__(cls) + setattr(provider, attribute, shape) + return provider + + +def _reviewer(provider): + reviewer = PRReviewer.__new__(PRReviewer) + reviewer.git_provider = provider + return reviewer + + +def test_providers_resolve_their_own_head_sha_shape(): + """Resolve each provider's head through the field its own API exposes.""" + for name, cls, attribute, shape in PROVIDER_SHAPES: + provider = _provider(cls, attribute, shape) + assert provider.get_pr_head_sha() == HEAD_SHA, name + + +def test_review_head_sha_reads_the_provider_hook(): + """Resolve a head for every provider in the matrix, whatever its API shape.""" + for name, cls, attribute, shape in PROVIDER_SHAPES: + reviewer = _reviewer(_provider(cls, attribute, shape)) + assert reviewer._review_head_sha() == HEAD_SHA, name + + +def test_unresolvable_head_stays_empty_rather_than_guessing(): + """Refuse to invent a head for a provider that has none to report. + + The reconciliation guard only resolves findings when both the previous and the + current head are non-empty and different, so an empty head safely refuses. + """ + provider = GitLabProvider.__new__(GitLabProvider) + provider.mr = SimpleNamespace(diff_refs={}) + assert provider.get_pr_head_sha() == "" + assert _reviewer(provider)._review_head_sha() == "" + + # A malformed payload degrades to empty instead of raising into the review run. + provider.mr = SimpleNamespace(diff_refs={"head_sha": 12345}) + assert provider.get_pr_head_sha() == "" + provider.mr = SimpleNamespace() + assert provider.get_pr_head_sha() == "" + + +def test_base_contract_defaults_to_empty(): + """Pin the base method as a no-op so a new provider opts in explicitly.""" + import inspect + + source = inspect.getsource(GitProvider.get_pr_head_sha) + returns_empty = 'return ""' in source + + assert returns_empty + # And no provider is left relying on the base by accident: each one that can + # resolve a head declares the hook itself. + for name, cls, _, _ in PROVIDER_SHAPES: + assert cls.get_pr_head_sha is not GitProvider.get_pr_head_sha, name + + +def test_head_sha_falls_back_when_the_hook_is_absent(): + """Resolve the head for a provider that does not define the hook.""" + for last_commit_id in ("legacy-sha", SimpleNamespace(sha="legacy-sha"), SimpleNamespace(id="legacy-sha")): + assert _reviewer(SimpleNamespace(last_commit_id=last_commit_id))._review_head_sha() == "legacy-sha" + + +def test_head_sha_is_empty_when_nothing_resolves(): + """Leave the head empty rather than stale when nothing resolves.""" + assert _reviewer(SimpleNamespace(last_commit_id=None))._review_head_sha() == "" diff --git a/tests/unittest/test_git_provider_lazy_imports.py b/tests/unittest/test_git_provider_lazy_imports.py new file mode 100644 index 0000000000..a32eb63076 --- /dev/null +++ b/tests/unittest/test_git_provider_lazy_imports.py @@ -0,0 +1,76 @@ +import subprocess +import sys + +_PROVIDER_MODULES = { + "azure": "pr_agent.git_providers.azuredevops_provider", + "bitbucket": "pr_agent.git_providers.bitbucket_provider", + "bitbucket_server": "pr_agent.git_providers.bitbucket_server_provider", + "codecommit": "pr_agent.git_providers.codecommit_provider", + "gerrit": "pr_agent.git_providers.gerrit_provider", + "gitea": "pr_agent.git_providers.gitea_provider", + "github": "pr_agent.git_providers.github_provider", + "gitlab": "pr_agent.git_providers.gitlab_provider", + "local": "pr_agent.git_providers.local_git_provider", + "plain-diff": "pr_agent.git_providers.plain_diff_provider", +} + + +def _run_python(source: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-c", source], + capture_output=True, + text=True, + timeout=60, + ) + + +def test_importing_git_providers_does_not_import_builtin_provider_modules(): + source = f""" +import sys + +import pr_agent.git_providers # noqa: F401 + +provider_modules = {list(_PROVIDER_MODULES.values())!r} +loaded = [module for module in provider_modules if module in sys.modules] +assert loaded == [], loaded +""" + + result = _run_python(source) + + assert result.returncode == 0, result.stderr + + +def test_registry_loads_only_selected_builtin_provider(): + source = f""" +import sys + +from pr_agent import git_providers + +provider_modules = {list(_PROVIDER_MODULES.values())!r} +provider_class = git_providers._GIT_PROVIDERS["github"] + +assert provider_class.__name__ == "GithubProvider" +loaded = [module for module in provider_modules if module in sys.modules] +assert loaded == ["pr_agent.git_providers.github_provider"], loaded +""" + + result = _run_python(source) + + assert result.returncode == 0, result.stderr + + +def test_package_level_provider_import_stays_compatible_and_lazy(): + source = f""" +import sys + +from pr_agent.git_providers import GitLabProvider + +provider_modules = {list(_PROVIDER_MODULES.values())!r} +assert GitLabProvider.__name__ == "GitLabProvider" +loaded = [module for module in provider_modules if module in sys.modules] +assert loaded == ["pr_agent.git_providers.gitlab_provider"], loaded +""" + + result = _run_python(source) + + assert result.returncode == 0, result.stderr diff --git a/tests/unittest/test_git_provider_method_contract.py b/tests/unittest/test_git_provider_method_contract.py index ab7ae4d65d..ee15a5ecb9 100644 --- a/tests/unittest/test_git_provider_method_contract.py +++ b/tests/unittest/test_git_provider_method_contract.py @@ -8,6 +8,7 @@ import inspect from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timezone from enum import Enum from types import SimpleNamespace from typing import get_type_hints @@ -53,6 +54,28 @@ class MethodContract: check_execution: bool = True +@dataclass(frozen=True) +class DeliberateMismatch: + reason: str + + +@dataclass(frozen=True) +class PredicateContract: + name: str + evidence: tuple[str, ...] + deliberate_mismatches: dict[str, DeliberateMismatch] + + +@dataclass(frozen=True) +class SuggestionOutcomeContract: + provider_name: str + build_provider: Callable[..., GitProvider] + make_fail: Callable[..., None] + make_succeed: Callable[..., None] + payload: list[dict] + deliberate_mismatch: DeliberateMismatch | None = None + + def _github(monkeypatch) -> GithubProvider: provider = GithubProvider.__new__(GithubProvider) provider.base_url = "https://api.github.example" @@ -125,6 +148,7 @@ def _bitbucket_server(monkeypatch) -> BitbucketServerProvider: }] return provider + def _bitbucket(monkeypatch) -> BitbucketProvider: provider = BitbucketProvider.__new__(BitbucketProvider) provider.pr = MagicMock() @@ -136,6 +160,38 @@ def _bitbucket(monkeypatch) -> BitbucketProvider: return provider +def _codecommit(monkeypatch) -> CodeCommitProvider: + provider = CodeCommitProvider.__new__(CodeCommitProvider) + provider.repo_name = "repo" + provider.pr_num = 7 + provider.pr_url = "https://us-east-1.console.aws.amazon.com/codesuite/codecommit/repositories/repo/pull-requests/7" + provider.pr = SimpleNamespace( + source_commit="source", + destination_commit="destination", + targets=[ + SimpleNamespace( + repository_name="repo", + source_commit="source", + destination_commit="destination", + ) + ], + ) + provider.codecommit_client = MagicMock() + provider.codecommit_client.get_comments_for_pull_request.return_value = [ + { + "repositoryName": "repo", + "beforeCommitId": "destination", + "afterCommitId": "source", + "comments": [{ + "commentId": "comment-1", + "content": COMMENT_BODY, + "creationDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + }], + } + ] + return provider + + def _bare(provider_type): """A provider with no backend wired at all: every call on it must succeed without one.""" return lambda monkeypatch: provider_type.__new__(provider_type) @@ -149,7 +205,7 @@ def _bare(provider_type): "azure-devops": (AzureDevopsProvider, _azure_devops), "bitbucket": (BitbucketProvider, _bitbucket), "bitbucket-server": (BitbucketServerProvider, _bitbucket_server), - "codecommit": (CodeCommitProvider, _bare(CodeCommitProvider)), + "codecommit": (CodeCommitProvider, _codecommit), "local": (LocalGitProvider, _bare(LocalGitProvider)), "plain-diff": (PlainDiffGitProvider, _bare(PlainDiffGitProvider)), "mosaico-diff": (DiffInputProvider, _bare(DiffInputProvider)), @@ -183,6 +239,30 @@ def _is_success(value): REACTION_TIERS = _tiers(supported=("github", "gitlab", "gitea"), not_implemented=("gerrit",)) +PREDICATE_CONTRACTS = ( + PredicateContract( + name="supports_review_comment_identity", + evidence=("edit_comment",), + deliberate_mismatches={ + "gitea": DeliberateMismatch( + "Gitea forwards identity arguments but cannot safely activate identity tracking " + "until it normalizes dictionary-shaped comment payloads." + ), + }, + ), + PredicateContract( + name="supports_thread_resolution", + evidence=("resolve_comment_thread",), + deliberate_mismatches={ + "gitlab": DeliberateMismatch( + "GitLab resolves note IDs while /ask_line addresses discussion IDs, so thread resolution " + "must remain disabled." + ), + }, + ), +) + + METHOD_CONTRACTS = ( MethodContract( name="get_commit_messages", @@ -197,8 +277,17 @@ def _is_success(value): noop_value=[], check_supported=_is_comment_sequence, tiers=_tiers( - supported=("github", "gitlab", "gitea", "gerrit", "azure-devops", "bitbucket-server", "bitbucket"), - not_implemented=("codecommit", "local"), + supported=( + "github", + "gitlab", + "gitea", + "gerrit", + "azure-devops", + "bitbucket-server", + "bitbucket", + "codecommit", + ), + not_implemented=("local",), ), # Implementations narrow the base `Iterable` (a paginated list, a list of SDK objects), # so the return annotation is checked by behaviour rather than by equality. @@ -229,6 +318,187 @@ def _is_success(value): check_return_annotation=False, check_execution=False, ), + MethodContract( + name="get_repo_context_ref", + args=(), + noop_value=None, + check_supported=lambda _: None, + tiers=_tiers( + supported=("github", "gitlab", "gitea", "azure-devops", "bitbucket", "bitbucket-server"), + ), + # Signature + return-annotation contract: the providers that fetch repo-context files + # must expose the same hook so the cache can key on the revision being read. + check_execution=False, + ), +) + + +SUGGESTION_PAYLOAD = [{ + "body": "description\n```suggestion\nnew\n```", + "relevant_file": "app.py", + "relevant_lines_start": 1, + "relevant_lines_end": 1, +}] + + +def _build_github_suggestion_provider(monkeypatch, tmp_path) -> GithubProvider: + provider = _github(monkeypatch) + provider.validate_comments_inside_hunks = lambda suggestions: suggestions + return provider + + +def _build_gitlab_suggestion_provider(monkeypatch, tmp_path) -> GitLabProvider: + provider = _gitlab(monkeypatch) + provider.resolve_outdated_inline_threads = MagicMock() + provider.get_diff_files = MagicMock(return_value=[SimpleNamespace(filename="app.py", head_file="orig\n")]) + return provider + + +def _build_gerrit_suggestion_provider(monkeypatch, tmp_path) -> GerritProvider: + provider = _gerrit(monkeypatch) + provider.repo_path = str(tmp_path) + (tmp_path / "app.py").write_text("orig\n") + monkeypatch.setattr("pr_agent.git_providers.gerrit_provider.upload_patch", lambda *_: "https://patch.example/1") + monkeypatch.setattr("pr_agent.git_providers.gerrit_provider.diff", lambda *_, **__: "patch") + monkeypatch.setattr("pr_agent.git_providers.gerrit_provider.reset_local_changes", lambda *_: None) + return provider + + +def _build_azure_devops_suggestion_provider(monkeypatch, tmp_path) -> AzureDevopsProvider: + provider = _azure_devops(monkeypatch) + provider.workspace_slug = "project" + provider.repo_slug = "repo" + provider.pr_num = 7 + provider.azure_devops_client = MagicMock() + provider._resolve_diff_file_path = MagicMock(return_value="/app.py") + provider._get_suggestion_end_offset = MagicMock(return_value=1) + return provider + + +def _build_codecommit_suggestion_provider(monkeypatch, tmp_path) -> CodeCommitProvider: + provider = _codecommit(monkeypatch) + provider.pr_num = 123 + provider.codecommit_client = MagicMock() + return provider + + +def _succeed_codecommit_suggestions(provider: CodeCommitProvider, monkeypatch, tmp_path): + provider._get_target_contexts_for_file = MagicMock(return_value=[{ + "repository_name": "repo", + "destination_commit": "dest", + "source_commit": "src", + }]) + + +def _build_local_suggestion_provider(monkeypatch, tmp_path) -> LocalGitProvider: + provider = _bare(LocalGitProvider)(monkeypatch) + provider.improve_path = str(tmp_path / "improve.md") + return provider + + +def _build_plain_diff_suggestion_provider(monkeypatch, tmp_path) -> PlainDiffGitProvider: + provider = _bare(PlainDiffGitProvider)(monkeypatch) + provider.output_path = None + return provider + + +SUGGESTION_OUTCOME_CONTRACTS = ( + SuggestionOutcomeContract( + provider_name="github", + build_provider=_build_github_suggestion_provider, + make_fail=lambda p, mp, tmp: setattr(p, "publish_inline_comments", MagicMock(return_value=False)), + make_succeed=lambda p, mp, tmp: setattr(p, "publish_inline_comments", MagicMock(return_value=True)), + payload=SUGGESTION_PAYLOAD, + ), + SuggestionOutcomeContract( + provider_name="gitlab", + build_provider=_build_gitlab_suggestion_provider, + make_fail=lambda p, mp, tmp: setattr( + p, "send_inline_comment", MagicMock(side_effect=RuntimeError("network down")) + ), + make_succeed=lambda p, mp, tmp: setattr(p, "send_inline_comment", MagicMock(return_value=True)), + payload=SUGGESTION_PAYLOAD, + deliberate_mismatch=DeliberateMismatch( + "GitLab unconditionally returns True; issue #3129 owns reporting total failures." + ), + ), + SuggestionOutcomeContract( + provider_name="gitea", + build_provider=lambda mp, tmp: _gitea(mp), + make_fail=lambda p, mp, tmp: setattr(p, "publish_inline_comments", MagicMock(return_value=False)), + make_succeed=lambda p, mp, tmp: setattr(p, "publish_inline_comments", MagicMock(return_value=True)), + payload=SUGGESTION_PAYLOAD, + ), + SuggestionOutcomeContract( + provider_name="gerrit", + build_provider=_build_gerrit_suggestion_provider, + make_fail=lambda p, mp, tmp: mp.setattr( + "pr_agent.git_providers.gerrit_provider.add_comment", + MagicMock(side_effect=RuntimeError("network down")), + ), + make_succeed=lambda p, mp, tmp: mp.setattr( + "pr_agent.git_providers.gerrit_provider.add_comment", lambda *_: None + ), + payload=SUGGESTION_PAYLOAD, + ), + SuggestionOutcomeContract( + provider_name="azure-devops", + build_provider=_build_azure_devops_suggestion_provider, + make_fail=lambda p, mp, tmp: setattr( + p.azure_devops_client, "create_thread", MagicMock(side_effect=RuntimeError("network down")) + ), + make_succeed=lambda p, mp, tmp: setattr(p.azure_devops_client, "create_thread", MagicMock()), + payload=SUGGESTION_PAYLOAD, + ), + SuggestionOutcomeContract( + provider_name="bitbucket", + build_provider=lambda mp, tmp: _bitbucket(mp), + make_fail=lambda p, mp, tmp: setattr(p, "publish_inline_comments", MagicMock(return_value=False)), + make_succeed=lambda p, mp, tmp: setattr(p, "publish_inline_comments", MagicMock(return_value=True)), + payload=SUGGESTION_PAYLOAD, + ), + SuggestionOutcomeContract( + provider_name="bitbucket-server", + build_provider=lambda mp, tmp: _bitbucket_server(mp), + make_fail=lambda p, mp, tmp: setattr(p, "publish_inline_comments", MagicMock(return_value=False)), + make_succeed=lambda p, mp, tmp: setattr(p, "publish_inline_comments", MagicMock(return_value=True)), + payload=SUGGESTION_PAYLOAD, + ), + SuggestionOutcomeContract( + provider_name="codecommit", + build_provider=_build_codecommit_suggestion_provider, + make_fail=lambda p, mp, tmp: setattr(p, "_get_target_contexts_for_file", MagicMock(return_value=[])), + make_succeed=_succeed_codecommit_suggestions, + payload=SUGGESTION_PAYLOAD, + ), + SuggestionOutcomeContract( + provider_name="local", + build_provider=_build_local_suggestion_provider, + make_fail=lambda p, mp, tmp: None, + make_succeed=lambda p, mp, tmp: None, + payload=SUGGESTION_PAYLOAD, + deliberate_mismatch=DeliberateMismatch( + "Local git provider writes suggestions to a local artifact file and unconditionally returns True." + ), + ), + SuggestionOutcomeContract( + provider_name="plain-diff", + build_provider=_build_plain_diff_suggestion_provider, + make_fail=lambda p, mp, tmp: None, + make_succeed=lambda p, mp, tmp: None, + payload=SUGGESTION_PAYLOAD, + deliberate_mismatch=DeliberateMismatch( + "Plain-diff provider renders suggestions to stdout or output file and unconditionally returns True." + ), + ), + SuggestionOutcomeContract( + provider_name="mosaico-diff", + build_provider=lambda mp, tmp: _bare(DiffInputProvider)(mp), + make_fail=lambda p, mp, tmp: None, + make_succeed=lambda p, mp, tmp: None, + payload=SUGGESTION_PAYLOAD, + deliberate_mismatch=DeliberateMismatch("Mosaico diff is a no-op provider that unconditionally returns True."), + ), ) @@ -241,12 +511,54 @@ def _rows(tier: Tier | None = None, check_execution_only: bool = False): yield pytest.param(provider_name, contract, id=f"{provider_name}-{contract.name}") +def _predicate_rows(): + for contract in PREDICATE_CONTRACTS: + for provider_name in PROVIDERS: + yield pytest.param(provider_name, contract, id=f"{provider_name}-{contract.name}") + + +def _has_evidence(provider_type: type[GitProvider], contract: PredicateContract) -> bool: + for name in contract.evidence: + member = getattr(provider_type, name, None) + if member is not getattr(GitProvider, name, None): + return True + return False + + def test_every_registered_provider_has_a_contract_row(): contracted = {provider_type for provider_type, _ in PROVIDERS.values()} assert set(_GIT_PROVIDERS.values()) <= contracted +@pytest.mark.parametrize("provider_name,contract", tuple(_predicate_rows())) +def test_predicate_truth_matches_evidence(provider_name: str, contract: PredicateContract): + provider_type, _ = PROVIDERS[provider_name] + mismatch = contract.deliberate_mismatches.get(provider_name) + predicate = getattr(provider_type.__new__(provider_type), contract.name) + + if mismatch: + assert mismatch.reason.strip() + assert predicate() is False + else: + assert predicate() is _has_evidence(provider_type, contract) + + +@pytest.mark.parametrize("contract", PREDICATE_CONTRACTS, ids=lambda contract: contract.name) +def test_predicate_mismatches_name_only_providers_with_evidence(contract: PredicateContract): + for provider_name in contract.deliberate_mismatches: + provider_type, _ = PROVIDERS[provider_name] + + assert _has_evidence(provider_type, contract) + + +@pytest.mark.parametrize("contract", PREDICATE_CONTRACTS, ids=lambda contract: contract.name) +def test_predicate_contract_evidence_members_exist_on_registered_providers(contract: PredicateContract): + assert contract.evidence + for member_name in contract.evidence: + assert any(hasattr(provider_type, member_name) for provider_type, _ in PROVIDERS.values()) + + @pytest.mark.parametrize("contract", METHOD_CONTRACTS, ids=lambda contract: contract.name) def test_every_contract_row_places_every_provider_in_a_tier(contract: MethodContract): assert set(contract.tiers) == set(PROVIDERS) @@ -313,3 +625,62 @@ def test_disable_eyes_short_circuits_before_any_backend_call(provider_name: str) provider = provider_type.__new__(provider_type) assert provider.add_eyes_reaction(COMMENT_ID, disable_eyes=True) is None + + +@pytest.mark.parametrize( + "provider_name", + [ + name + for name, (cls, _) in PROVIDERS.items() + if "publish_code_suggestions" in cls.__dict__ + ], +) +def test_publish_code_suggestions_declares_bool_return(provider_name: str): + provider_type, _ = PROVIDERS[provider_name] + hints = get_type_hints(provider_type.publish_code_suggestions) + assert hints.get("return") is bool + + +def test_every_provider_overriding_publish_code_suggestions_has_a_contract_row(): + overriding = { + name + for name, (cls, _) in PROVIDERS.items() + if "publish_code_suggestions" in cls.__dict__ + } + contracted = {contract.provider_name for contract in SUGGESTION_OUTCOME_CONTRACTS} + assert len(contracted) == len(SUGGESTION_OUTCOME_CONTRACTS) + assert contracted == overriding + + +@pytest.mark.parametrize( + "contract", + [ + pytest.param(contract, id=f"{contract.provider_name}-publish_code_suggestions-failure") + for contract in SUGGESTION_OUTCOME_CONTRACTS + ], +) +def test_publish_code_suggestions_returns_false_on_total_failure( + contract: SuggestionOutcomeContract, monkeypatch, tmp_path +): + provider = contract.build_provider(monkeypatch, tmp_path) + contract.make_fail(provider, monkeypatch, tmp_path) + if contract.deliberate_mismatch is not None: + assert contract.deliberate_mismatch.reason.strip() + assert provider.publish_code_suggestions(contract.payload) is True + else: + assert provider.publish_code_suggestions(contract.payload) is False + + +@pytest.mark.parametrize( + "contract", + [ + pytest.param(contract, id=f"{contract.provider_name}-publish_code_suggestions-success") + for contract in SUGGESTION_OUTCOME_CONTRACTS + ], +) +def test_publish_code_suggestions_returns_true_on_success( + contract: SuggestionOutcomeContract, monkeypatch, tmp_path +): + provider = contract.build_provider(monkeypatch, tmp_path) + contract.make_succeed(provider, monkeypatch, tmp_path) + assert provider.publish_code_suggestions(contract.payload) is True diff --git a/tests/unittest/test_git_provider_utils.py b/tests/unittest/test_git_provider_utils.py index 4048f73335..6dc7105463 100644 --- a/tests/unittest/test_git_provider_utils.py +++ b/tests/unittest/test_git_provider_utils.py @@ -112,6 +112,8 @@ def test_handle_configurations_errors_uses_persistent_comment_when_supported(): assert comment["final_update_message"] is False assert "PR-Agent failed to apply 'local' repo settings" in comment["body"] assert "Invalid value" in comment["body"] + assert "https://docs.pr-agent.ai/usage-guide/configuration_options/" in comment["body"] + assert "qodo-merge-docs.qodo.ai" not in comment["body"] assert "```toml\n[config]\nmodel =\n```" in comment["body"] assert "
      Configuration content:" in comment["body"] diff --git a/tests/unittest/test_gitea_comment_webhooks.py b/tests/unittest/test_gitea_comment_webhooks.py new file mode 100644 index 0000000000..9a448aa7d1 --- /dev/null +++ b/tests/unittest/test_gitea_comment_webhooks.py @@ -0,0 +1,150 @@ +import hashlib +import hmac +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException +from starlette.background import BackgroundTasks +from starlette.requests import Request +from starlette.responses import Response + +from pr_agent.servers import gitea_app + +PR_URL = "https://gitea.example.test/owner/repo/pulls/7" +WEBHOOK_SECRET = "test-webhook-secret" + + +@pytest.fixture +def agent(monkeypatch): + """Isolate command execution and request settings without bypassing signature validation.""" + agent = SimpleNamespace(handle_request=AsyncMock()) + settings = SimpleNamespace(gitea=SimpleNamespace(webhook_secret=WEBHOOK_SECRET)) + monkeypatch.setattr(gitea_app, "PRAgent", lambda: agent) + monkeypatch.setattr(gitea_app, "get_settings", lambda: settings) + monkeypatch.setattr(gitea_app, "global_settings", settings) + monkeypatch.setattr(gitea_app, "context", {}) + return agent + + +def _payload(**overrides): + """Build the native payload for a newly created PR timeline comment.""" + return { + "action": "created", + "is_pull": True, + "issue": {"number": 7}, + "pull_request": {"url": PR_URL}, + "comment": {"body": "/review"}, + **overrides, + } + + +def _request(payload, event="issue_comment", event_type="pull_request_comment", signature="valid"): + """Sign a request using Gitea's normalized event and specific event-type headers.""" + body = json.dumps(payload).encode("utf-8") + headers = [ + (b"content-type", b"application/json"), + (b"x-gitea-event", event.encode("ascii")), + (b"x-gitea-event-type", event_type.encode("ascii")), + ] + if signature is not None: + digest = hmac.new(WEBHOOK_SECRET.encode(), body, hashlib.sha256).hexdigest() + headers.append((b"x-gitea-signature", (digest if signature == "valid" else signature).encode())) + + async def receive(): + return {"type": "http.request", "body": body, "more_body": False} + + return Request({"type": "http", "method": "POST", "path": "/api/v1/gitea_webhooks", "headers": headers}, receive) + + +async def _deliver(request): + """Execute the webhook's real dispatch and queued background task.""" + tasks = BackgroundTasks() + result = await gitea_app.handle_gitea_webhooks(tasks, request, Response()) + assert result == {} + assert len(tasks.tasks) == 1 + await tasks() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("command", ["/review", '/ask "Explain this change"', "/improve"]) +async def test_signed_pr_timeline_comment_dispatches_once(agent, command): + # Gitea normalizes HookEventPullRequestComment to X-Gitea-Event: issue_comment. + await _deliver(_request(_payload(comment={"body": command}))) + + agent.handle_request.assert_awaited_once_with(PR_URL, command) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "overrides", + [ + {"action": "edited"}, + {"action": "deleted"}, + {"action": None}, + {"comment": {"body": "A regular comment"}}, + {"comment": {"body": ""}}, + {"comment": {}}, + {"pull_request": {}}, + ], +) +async def test_ineligible_pr_timeline_comment_does_not_dispatch(agent, overrides): + await _deliver(_request(_payload(**overrides))) + + agent.handle_request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_issue_only_comment_does_not_dispatch(agent): + payload = _payload(is_pull=False) + del payload["pull_request"] + + await _deliver(_request(payload, event_type="issue_comment")) + + agent.handle_request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_review_comment_event_is_not_a_timeline_command(agent): + payload = { + "action": "reviewed", + "pull_request": {"url": PR_URL}, + "review": {"type": "comment", "content": "/review"}, + } + await _deliver(_request(payload, event="pull_request_comment", event_type="pull_request_review_comment")) + + agent.handle_request.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("signature", "status"), [(None, 400), ("0" * 64, 401)]) +async def test_unsigned_or_invalid_comment_is_rejected_before_dispatch(agent, signature, status): + tasks = BackgroundTasks() + with pytest.raises(HTTPException) as caught: + await gitea_app.handle_gitea_webhooks(tasks, _request(_payload(), signature=signature), Response()) + + assert caught.value.status_code == status + assert tasks.tasks == [] + agent.handle_request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_unconfigured_secret_rejects_every_webhook(monkeypatch): + # Like the GitHub app, the Gitea webhook must fail closed when the secret is + # not configured instead of accepting unauthenticated events that any caller + # could forge to trigger expensive commands. + settings = SimpleNamespace(gitea=SimpleNamespace(webhook_secret="")) + monkeypatch.setattr(gitea_app, "get_settings", lambda: settings) + monkeypatch.setattr(gitea_app, "global_settings", settings) + monkeypatch.setattr(gitea_app, "context", {}) + agent = SimpleNamespace(handle_request=AsyncMock()) + monkeypatch.setattr(gitea_app, "PRAgent", lambda: agent) + + tasks = BackgroundTasks() + with pytest.raises(HTTPException) as caught: + await gitea_app.handle_gitea_webhooks(tasks, _request(_payload()), Response()) + + assert caught.value.status_code == 403 + assert tasks.tasks == [] + agent.handle_request.assert_not_awaited() diff --git a/tests/unittest/test_gitea_e2e_branch_api.py b/tests/unittest/test_gitea_e2e_branch_api.py new file mode 100644 index 0000000000..9ae9b20a05 --- /dev/null +++ b/tests/unittest/test_gitea_e2e_branch_api.py @@ -0,0 +1,169 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + + +def _setup_gitea_case(monkeypatch): + """Return an isolated Gitea E2E module, mocked HTTP client, and logger.""" + import pr_agent.log as log_module + + monkeypatch.setattr(log_module, "setup_logger", MagicMock()) + from tests.e2e_tests import test_gitea_app as gitea_e2e + + settings = SimpleNamespace( + config=SimpleNamespace(git_provider=None), + get={ + "GITEA.URL": "https://gitea.example.test", + "GITEA.TOKEN": "test-token", + }.get, + ) + http = MagicMock(spec=["get", "post", "put", "patch", "delete"]) + test_logger = MagicMock() + monkeypatch.setattr(gitea_e2e, "get_settings", lambda: settings) + monkeypatch.setattr(gitea_e2e, "requests", http) + monkeypatch.setattr(gitea_e2e, "logger", test_logger) + return gitea_e2e, http, test_logger + + +def _assert_native_create_contract(http): + """Assert that the first POST uses Gitea's native branch API.""" + assert http.post.call_count >= 1 + create_call = http.post.call_args_list[0] + assert create_call.args[0] == "https://gitea.example.test/api/v1/repos/codiumai/pr-agent-tests/branches" + assert create_call.kwargs["json"]["old_ref_name"] == "main" + new_branch = create_call.kwargs["json"]["new_branch_name"] + assert new_branch.startswith("gitea_app_e2e_test-") + assert set(create_call.kwargs["json"]) == {"new_branch_name", "old_ref_name"} + return new_branch + + +def _expected_headers(): + """Return the headers used by the isolated Gitea E2E test.""" + return { + "Authorization": "token test-token", + "Content-Type": "application/json", + "Accept": "application/json", + } + + +def test_gitea_e2e_does_not_delete_branch_when_creation_fails(monkeypatch): + """Do not delete a branch that this test never confirmed it created.""" + gitea_e2e, http, _ = _setup_gitea_case(monkeypatch) + creation_failure = RuntimeError("branch creation rejected") + http.post.return_value.raise_for_status.side_effect = creation_failure + + with pytest.raises(RuntimeError) as caught: + gitea_e2e.test_e2e_run_gitea_app() + + assert caught.value is creation_failure + _assert_native_create_contract(http) + assert http.post.call_count == 1 + http.get.assert_not_called() + http.put.assert_not_called() + http.patch.assert_not_called() + http.delete.assert_not_called() + + +def test_gitea_e2e_cleans_up_branch_after_confirmed_creation(monkeypatch): + """Use the native branch DELETE after creation succeeds and a later step fails.""" + gitea_e2e, http, _ = _setup_gitea_case(monkeypatch) + post_creation_failure = RuntimeError("file update failed") + http.put.side_effect = post_creation_failure + + with pytest.raises(RuntimeError) as caught: + gitea_e2e.test_e2e_run_gitea_app() + + assert caught.value is post_creation_failure + new_branch = _assert_native_create_contract(http) + http.patch.assert_not_called() + http.delete.assert_called_once_with( + f"https://gitea.example.test/api/v1/repos/codiumai/pr-agent-tests/branches/{new_branch}", + headers=_expected_headers(), + ) + http.delete.return_value.raise_for_status.assert_called_once_with() + + +def test_gitea_e2e_branch_cleanup_survives_pr_cleanup_failure(monkeypatch): + """Delete the run-owned branch even when fallback PR closure fails.""" + gitea_e2e, http, test_logger = _setup_gitea_case(monkeypatch) + pr_response = MagicMock() + pr_response.json.return_value = {"number": 123} + http.post.side_effect = [MagicMock(), pr_response] + close_failure = RuntimeError("pull request cleanup failed") + http.patch.return_value.raise_for_status.side_effect = close_failure + monkeypatch.setattr(gitea_e2e, "NUM_MINUTES", 0) + + with pytest.raises(AssertionError): + gitea_e2e.test_e2e_run_gitea_app() + + new_branch = _assert_native_create_contract(http) + http.patch.assert_called_once() + http.patch.return_value.raise_for_status.assert_called_once_with() + http.delete.assert_called_once_with( + f"https://gitea.example.test/api/v1/repos/codiumai/pr-agent-tests/branches/{new_branch}", + headers=_expected_headers(), + ) + http.delete.return_value.raise_for_status.assert_called_once_with() + test_logger.error.assert_any_call(f"Failed to clean up after test: {close_failure}") + + +def test_gitea_e2e_reports_branch_cleanup_http_failure(monkeypatch): + """Log a fallback branch DELETE failure surfaced by raise_for_status().""" + gitea_e2e, http, test_logger = _setup_gitea_case(monkeypatch) + post_creation_failure = RuntimeError("file update failed") + cleanup_failure = RuntimeError("branch cleanup failed") + http.put.side_effect = post_creation_failure + http.delete.return_value.raise_for_status.side_effect = cleanup_failure + + with pytest.raises(RuntimeError) as caught: + gitea_e2e.test_e2e_run_gitea_app() + + assert caught.value is post_creation_failure + new_branch = _assert_native_create_contract(http) + http.delete.assert_called_once_with( + f"https://gitea.example.test/api/v1/repos/codiumai/pr-agent-tests/branches/{new_branch}", + headers=_expected_headers(), + ) + http.delete.return_value.raise_for_status.assert_called_once_with() + test_logger.error.assert_any_call(f"Failed to clean up after test: {cleanup_failure}") + + +def test_gitea_e2e_does_not_repeat_successful_pr_cleanup(monkeypatch): + """Do not close an already-closed PR again when normal branch deletion fails.""" + gitea_e2e, http, _ = _setup_gitea_case(monkeypatch) + file_response = MagicMock() + file_response.status_code = 200 + file_response.json.return_value = {"sha": "file-sha"} + results = MagicMock(return_value=[]) + monkeypatch.setattr(gitea_e2e, "_missing_gitea_tool_results", results) + http.get.side_effect = [file_response] + + pr_response = MagicMock() + pr_response.json.return_value = {"number": 123} + http.post.side_effect = [MagicMock(), pr_response] + + cleanup_failure = RuntimeError("normal branch cleanup failed") + failed_delete = MagicMock() + failed_delete.raise_for_status.side_effect = cleanup_failure + successful_fallback_delete = MagicMock() + http.delete.side_effect = [failed_delete, successful_fallback_delete] + monkeypatch.setattr(gitea_e2e.time, "sleep", lambda _: None) + + with pytest.raises(RuntimeError) as caught: + gitea_e2e.test_e2e_run_gitea_app() + + assert caught.value is cleanup_failure + new_branch = _assert_native_create_contract(http) + results.assert_called_once_with( + "https://gitea.example.test/api/v1/repos/codiumai/pr-agent-tests", 123, _expected_headers() + ) + http.patch.assert_called_once_with( + "https://gitea.example.test/api/v1/repos/codiumai/pr-agent-tests/pulls/123", + headers=_expected_headers(), + json={"state": "closed"}, + ) + assert http.delete.call_count == 2 + assert http.delete.call_args_list[0].args[0].endswith(f"/branches/{new_branch}") + assert http.delete.call_args_list[1].args[0].endswith(f"/branches/{new_branch}") + successful_fallback_delete.raise_for_status.assert_called_once_with() diff --git a/tests/unittest/test_gitea_e2e_results.py b/tests/unittest/test_gitea_e2e_results.py new file mode 100644 index 0000000000..50f2aad57d --- /dev/null +++ b/tests/unittest/test_gitea_e2e_results.py @@ -0,0 +1,230 @@ +import base64 +import json +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from requests import Response +from requests.exceptions import ConnectionError, HTTPError, JSONDecodeError, SSLError, Timeout + +from tests.e2e_tests import test_gitea_app as gitea_e2e + +REPO_API = "https://gitea.example.test/api/v1/repos/codiumai/pr-agent-tests" +DESCRIPTION = "### **PR Type**\nbug fix\n\n### **Description**\n- Update the CLI." +REVIEW = {"body": "## Team review\n\n\n\nReview results."} +IMPROVE = {"body": "## Team suggestions\n\n\n\nSuggested changes."} + + +def _response(payload, status=200): + """Use real Requests status and JSON handling without a network connection.""" + response = Response() + response.status_code = status + response.url = REPO_API + response._content = json.dumps(payload).encode("utf-8") + response._content_consumed = True + return response + + +@pytest.fixture +def case(monkeypatch): + """Isolate the E2E settings, HTTP calls, logging, and polling delays.""" + settings = SimpleNamespace( + config=SimpleNamespace(git_provider=None), + get={"GITEA.URL": "https://gitea.example.test", "GITEA.TOKEN": "test-token"}.get, + ) + http = MagicMock(spec=["get", "post", "put", "patch", "delete"]) + sleep = MagicMock() + monkeypatch.setattr(gitea_e2e, "get_settings", lambda: settings) + monkeypatch.setattr(gitea_e2e, "requests", http) + monkeypatch.setattr(gitea_e2e, "logger", MagicMock()) + monkeypatch.setattr(gitea_e2e.time, "sleep", sleep) + monkeypatch.setattr(gitea_e2e, "NUM_MINUTES", 2) + + def get(url, **kwargs): + if "/contents/" in url: + return _response({"sha": "file-sha"}) + if url == f"{REPO_API}/pulls/123": + return _response({"body": DESCRIPTION}) + if url == f"{REPO_API}/issues/123/comments": + return _response([REVIEW, IMPROVE]) + raise AssertionError(f"Unexpected GET: {url}") + + def post(url, **kwargs): + if url.endswith("/branches"): + return _response({}, 201) + if "/contents/" in url: + return _response({}, 201) + if url.endswith("/pulls"): + return _response({"number": 123}, 201) + raise AssertionError(f"Unexpected POST: {url}") + + http.get.side_effect = get + http.post.side_effect = post + http.put.return_value = _response({}) + http.patch.return_value = _response({}) + http.delete.return_value = _response(None, 204) + return http, sleep + + +@pytest.mark.parametrize("file_status", [200, 404]) +def test_e2e_updates_existing_files_and_creates_only_missing_files(case, file_status): + http, sleep = case + original_get = http.get.side_effect + http.get.side_effect = lambda url, **kwargs: ( + _response({"sha": "file-sha"}, file_status) if "/contents/" in url else original_get(url, **kwargs) + ) + + gitea_e2e.test_e2e_run_gitea_app() + + writes = [call for call in http.mock_calls if call[0] in {"post", "put"} and "/contents/" in call.args[0]] + assert len(writes) == 1 + write = writes[0] + assert write[0] == ("post" if file_status == 404 else "put") + assert write.args[0] == f"{REPO_API}/contents/{gitea_e2e.FILE_PATH}" + payload = write.kwargs["json"] + assert payload["branch"] == http.post.call_args_list[0].kwargs["json"]["new_branch_name"] + assert base64.b64decode(payload["content"]).decode() == gitea_e2e.NEW_FILE_CONTENT + assert payload["message"] == ("Add cli_pip.py" if file_status == 404 else "Update cli_pip.py") + assert ("sha" in payload) == (file_status == 200) + if file_status == 200: + assert payload["sha"] == "file-sha" + assert write.kwargs["headers"]["Authorization"] == "token test-token" + sleep.assert_called_once_with(60) + http.patch.assert_called_once() + http.delete.assert_called_once() + + +@pytest.mark.parametrize("status", [401, 403, 429, 500, 503]) +def test_file_lookup_http_errors_do_not_trigger_a_write(case, status): + http, _ = case + response = _response({}, status) + http.get.return_value = response + http.get.side_effect = None + + with pytest.raises(HTTPError) as caught: + gitea_e2e.test_e2e_run_gitea_app() + + assert caught.value.response is response + http.put.assert_not_called() + assert http.post.call_count == 1 + http.delete.assert_called_once() + + +@pytest.mark.parametrize("error", [Timeout("timeout"), ConnectionError("connection"), SSLError("certificate")]) +def test_file_lookup_transport_errors_preserve_the_original_exception(case, error): + http, _ = case + http.get.side_effect = error + + with pytest.raises(type(error)) as caught: + gitea_e2e.test_e2e_run_gitea_app() + + assert caught.value is error + http.put.assert_not_called() + assert http.post.call_count == 1 + http.delete.assert_called_once() + + +@pytest.mark.parametrize("malformed_json", [True, False]) +def test_invalid_file_metadata_is_not_treated_as_a_missing_file(case, malformed_json): + http, _ = case + response = _response({}) + if malformed_json: + response._content = b"{" + http.get.side_effect = None + http.get.return_value = response + + with pytest.raises(JSONDecodeError if malformed_json else KeyError): + gitea_e2e.test_e2e_run_gitea_app() + + http.put.assert_not_called() + assert http.post.call_count == 1 + http.delete.assert_called_once() + + +@pytest.mark.parametrize("missing", ["/describe", "/review", "/improve"]) +def test_missing_tool_output_times_out_with_its_name_and_cleans_up(case, missing): + http, sleep = case + original_get = http.get.side_effect + + def get(url, **kwargs): + if url == f"{REPO_API}/pulls/123" and missing == "/describe": + return _response({"body": "update cli_pip.py"}) + if url == f"{REPO_API}/issues/123/comments": + comments = [REVIEW, IMPROVE] + if missing == "/review": + comments = [IMPROVE] + elif missing == "/improve": + comments = [REVIEW] + return _response(comments) + return original_get(url, **kwargs) + + http.get.side_effect = get + + with pytest.raises(AssertionError, match=missing): + gitea_e2e.test_e2e_run_gitea_app() + + assert sleep.call_count == 2 + http.patch.assert_called_once() + http.delete.assert_called_once() + + +def test_polling_waits_for_results_instead_of_failing_on_comment_count(case): + http, sleep = case + original_get = http.get.side_effect + poll = {"number": 0} + + def get(url, **kwargs): + if url == f"{REPO_API}/pulls/123": + poll["number"] += 1 + body = "update cli_pip.py" if poll["number"] == 1 else DESCRIPTION + return _response({"body": body}) + if url == f"{REPO_API}/issues/123/comments": + if poll["number"] == 1: + comments = [{"body": f"unrelated {i}"} for i in range(8)] + return _response(comments) + return original_get(url, **kwargs) + + http.get.side_effect = get + + gitea_e2e.test_e2e_run_gitea_app() + + assert sleep.call_count == 2 + http.patch.assert_called_once() + http.delete.assert_called_once() + + +def test_result_detection_accepts_no_suggestions_and_reads_comments_once(case): + http, _ = case + no_suggestions = {"body": "## No suggestions\n\n"} + + def get(url, **kwargs): + if url == f"{REPO_API}/pulls/123": + return _response({"body": DESCRIPTION}) + if url == f"{REPO_API}/issues/123/comments": + return _response([REVIEW, no_suggestions]) + raise AssertionError(f"Unexpected GET: {url}") + + http.get.side_effect = get + + assert gitea_e2e._missing_gitea_tool_results(REPO_API, 123, {"Authorization": "token test"}) == [] + pr_call = next(call for call in http.get.call_args_list if call.args[0] == f"{REPO_API}/pulls/123") + comment_calls = [call for call in http.get.call_args_list if "/comments" in call.args[0]] + assert pr_call.kwargs["timeout"] == 30 + assert len(comment_calls) == 1 + assert comment_calls[0].kwargs["timeout"] == 30 + + +def test_result_detection_reports_missing_tools_without_comment_order_assumptions(case): + http, _ = case + + def get(url, **kwargs): + if url == f"{REPO_API}/pulls/123": + return _response({"body": "update cli_pip.py"}) + if url == f"{REPO_API}/issues/123/comments": + comments = [IMPROVE, {"body": "unrelated"}, REVIEW] + return _response(comments) + raise AssertionError(f"Unexpected GET: {url}") + + http.get.side_effect = get + + assert gitea_e2e._missing_gitea_tool_results(REPO_API, 123, {}) == ["/describe"] diff --git a/tests/unittest/test_gitea_provider.py b/tests/unittest/test_gitea_provider.py index 426ae2064d..4845082276 100644 --- a/tests/unittest/test_gitea_provider.py +++ b/tests/unittest/test_gitea_provider.py @@ -1189,11 +1189,16 @@ def test_gitea_persistent_wrapper_preserves_identity_and_result(): published = object() class RecordingGiteaProvider: + # Gitea no longer overrides this: the base implementation persists. publish_persistent_comment = GiteaProvider.publish_persistent_comment + supports_comment_editing = GitProvider.supports_comment_editing def __init__(self): self.calls = [] + def edit_comment(self, comment, body): + return True + def publish_persistent_comment_full( self, pr_comment, initial_header, update_header=True, name="review", final_update_message=True, as_thread=False, identity_marker=None, @@ -1456,3 +1461,29 @@ def test_no_ignore_rules_keeps_all_files( names = [f.filename for f in provider.get_diff_files()] assert sorted(names) == ["api/schema.d.ts", "generated/client.py", "src/application.py"] + + @patch("pr_agent.git_providers.gitea_provider.giteapy.ApiClient") + @patch("pr_agent.git_providers.gitea_provider.RepoApi") + @patch("pr_agent.git_providers.gitea_provider.get_settings") + def test_large_pr_skips_unused_head_content_fetches( + self, mock_get_settings, mock_repo_api_cls, mock_api_client_cls + ): + provider = self._build_provider(mock_repo_api_cls, mock_get_settings, mock_api_client_cls) + repo_api = mock_repo_api_cls.return_value + + with request_cycle_context({}), patch( + "pr_agent.git_providers.gitea_provider.MAX_FILES_ALLOWED_FULL", 2 + ): + context["settings"] = copy.deepcopy(global_settings) + context["settings"].ignore.glob = [] + context["settings"].ignore.regex = [] + + diff_files = provider.get_diff_files() + + head_calls = [ + call.kwargs["filepath"] + for call in repo_api.get_file_content.call_args_list + if call.kwargs.get("commit_sha") == "head-sha" + ] + assert head_calls == ["generated/client.py"] + assert [file.head_file for file in diff_files] == ["file content", "", ""] diff --git a/tests/unittest/test_github_action_output.py b/tests/unittest/test_github_action_output.py index ddd0eac906..b28beba514 100644 --- a/tests/unittest/test_github_action_output.py +++ b/tests/unittest/test_github_action_output.py @@ -1,7 +1,8 @@ import json import os -from pr_agent.algo.utils import get_settings, github_action_output +from pr_agent.algo.run_output import github_action_output +from pr_agent.algo.utils import get_settings class TestGitHubOutput: diff --git a/tests/unittest/test_github_action_runner_core.py b/tests/unittest/test_github_action_runner_core.py index 489ae91a95..9d7529c96e 100644 --- a/tests/unittest/test_github_action_runner_core.py +++ b/tests/unittest/test_github_action_runner_core.py @@ -1,10 +1,13 @@ import copy import json +from unittest.mock import Mock import pytest +import pr_agent.agent.pr_agent as pr_agent_module import pr_agent.servers.github_action_runner as github_action_runner from pr_agent.config_loader import get_settings +from pr_agent.git_providers.github_provider import IncompletePullRequestFilesError def test_is_true_accepts_bool_and_case_insensitive_true_string(): @@ -24,6 +27,64 @@ async def test_run_action_returns_when_required_env_is_missing(monkeypatch, caps assert "GITHUB_EVENT_NAME not set" in capsys.readouterr().out +@pytest.mark.asyncio +async def test_direct_auto_tool_notifies_and_reraises_incomplete_constructor_error(monkeypatch): + error = IncompletePullRequestFilesError("private repository details") + notify = Mock() + + class IncompleteTool: + def __init__(self, _pr_url): + raise error + + monkeypatch.setattr(github_action_runner, "publish_incomplete_github_files_comment", notify) + + with pytest.raises(IncompletePullRequestFilesError) as raised: + await github_action_runner._run_auto_tool(IncompleteTool, "https://example/pr/1") + + assert raised.value is error + notify.assert_called_once_with("https://example/pr/1") + + +@pytest.mark.asyncio +async def test_direct_auto_tool_leaves_unexpected_constructor_error_unchanged(monkeypatch): + error = RuntimeError("unrelated") + notify = Mock() + + class BrokenTool: + def __init__(self, _pr_url): + raise error + + monkeypatch.setattr(github_action_runner, "publish_incomplete_github_files_comment", notify) + + with pytest.raises(RuntimeError) as raised: + await github_action_runner._run_auto_tool(BrokenTool, "https://example/pr/1") + + assert raised.value is error + notify.assert_not_called() + + +@pytest.mark.asyncio +async def test_direct_auto_tool_publishes_and_preserves_error_when_comment_decode_fails( + monkeypatch +): + error = IncompletePullRequestFilesError("private repository details") + provider = Mock() + provider.get_issue_comments_newest_first.return_value = [object()] + provider._get_comment_body.side_effect = RuntimeError("comment decoding failed") + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _url: provider) + + class IncompleteTool: + def __init__(self, _pr_url): + raise error + + with pytest.raises(IncompletePullRequestFilesError) as raised: + await github_action_runner._run_auto_tool(IncompleteTool, "https://example/pr/1") + + assert raised.value is error + provider.publish_comment.assert_called_once() + + @pytest.mark.asyncio async def test_run_action_invokes_enabled_auto_tools_for_pull_request_event(monkeypatch, tmp_path): settings = get_settings() @@ -272,7 +333,9 @@ def fake_get_setting_or_env(key, default=None): "GITHUB_ACTION_CONFIG.ENABLE_OUTPUT": True, "GITHUB_ACTION_CONFIG.REVIEW_STATES": '["changes_requested"]', "GITHUB_ACTION_CONFIG.REVIEW_AUTHOR_TYPES": '["User"]', - "GITHUB_ACTION_CONFIG.REVIEW_COMMANDS": '["/review"]', + "GITHUB_ACTION_CONFIG.REVIEW_COMMANDS": json.dumps([ + "/review --pr_reviewer.extra_instructions='be concise please'" + ]), "GITHUB_ACTION_CONFIG.FEEDBACK_ON_DRAFT_PR": False, } return values.get(key, default) @@ -281,7 +344,10 @@ def fake_get_setting_or_env(key, default=None): await github_action_runner.run_action() - assert handled == [("https://api.github.com/repos/org/repo/pulls/1", "/review")] + assert handled == [( + "https://api.github.com/repos/org/repo/pulls/1", + ["/review", '--pr_reviewer.extra_instructions="be concise please"'], + )] @pytest.mark.asyncio @@ -384,7 +450,7 @@ async def test_action_review_config_falls_back_to_app_and_overrides_it( app_review_states=["changes_requested"], app_review_commands=["/app-review"], ) - assert handled == [("https://api.github.com/repos/org/repo/pulls/1", "/app-review")] + assert handled == [("https://api.github.com/repos/org/repo/pulls/1", ["/app-review"])] handled = await _run_review_action_with_settings( monkeypatch, @@ -396,7 +462,7 @@ async def test_action_review_config_falls_back_to_app_and_overrides_it( "review_commands": ["/action-review"], }, ) - assert handled == [("https://api.github.com/repos/org/repo/pulls/1", "/action-review")] + assert handled == [("https://api.github.com/repos/org/repo/pulls/1", ["/action-review"])] @pytest.mark.asyncio @@ -452,7 +518,9 @@ async def handle_request(self, url, body, notify=None): @pytest.mark.asyncio async def test_synchronize_event_triggers_push_commands(monkeypatch, tmp_path, restore_github_settings): handled = [] - _patch_synchronize_deps(monkeypatch, handled, ["/describe", "/improve"]) + _patch_synchronize_deps(monkeypatch, handled, [ + '/describe --pr_description.extra_instructions="true"', ["/improve", "literal 'text'"] + ]) monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_synchronize_event(tmp_path))) monkeypatch.setenv("GITHUB_TOKEN", "token") @@ -460,11 +528,54 @@ async def test_synchronize_event_triggers_push_commands(monkeypatch, tmp_path, r await github_action_runner.run_action() assert handled == [ - ("https://api.github.com/repos/org/repo/pulls/1", "/describe"), - ("https://api.github.com/repos/org/repo/pulls/1", "/improve"), + ("https://api.github.com/repos/org/repo/pulls/1", ["/describe", '--pr_description.extra_instructions="true"']), + ("https://api.github.com/repos/org/repo/pulls/1", ["/improve", "literal 'text'"]), ] +@pytest.mark.parametrize("command", ['/review --pr_reviewer.extra_instructions="unfinished', " "]) +def test_invalid_configured_command_fails_action_but_continues( + monkeypatch, tmp_path, restore_github_settings, command +): + handled = [] + _patch_synchronize_deps(monkeypatch, handled, [command, "/review"]) + monkeypatch.setattr(github_action_runner, "litellm_callbacks_registered", lambda: False) + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_synchronize_event(tmp_path))) + monkeypatch.setenv("GITHUB_TOKEN", "token") + + with pytest.raises(SystemExit) as error: + github_action_runner.main() + + assert error.value.code == 1 + assert handled == [("https://api.github.com/repos/org/repo/pulls/1", ["/review"])] + +def test_action_exits_nonzero_when_command_fails_and_continues( + monkeypatch, tmp_path, restore_github_settings +): + handled = [] + _patch_synchronize_deps(monkeypatch, handled, ["/review", "/improve"]) + + class FakeAgent: + async def handle_request(self, url, body, notify=None): + handled.append((url, body)) + return body != ["/review"] + + monkeypatch.setattr(github_action_runner, "PRAgent", FakeAgent) + monkeypatch.setattr(github_action_runner, "litellm_callbacks_registered", lambda: False) + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_synchronize_event(tmp_path))) + monkeypatch.setenv("GITHUB_TOKEN", "token") + + with pytest.raises(SystemExit) as exc_info: + github_action_runner.main() + + assert exc_info.value.code == 1 + assert handled == [ + ("https://api.github.com/repos/org/repo/pulls/1", ["/review"]), + ("https://api.github.com/repos/org/repo/pulls/1", ["/improve"]), + ] + @pytest.mark.asyncio async def test_synchronize_skips_when_push_trigger_disabled(monkeypatch, tmp_path, restore_github_settings): handled = [] @@ -505,8 +616,8 @@ async def test_synchronize_event_triggers_push_commands_on_pull_request_target( await github_action_runner.run_action() assert handled == [ - ("https://api.github.com/repos/org/repo/pulls/1", "/describe"), - ("https://api.github.com/repos/org/repo/pulls/1", "/improve"), + ("https://api.github.com/repos/org/repo/pulls/1", ["/describe"]), + ("https://api.github.com/repos/org/repo/pulls/1", ["/improve"]), ] @@ -557,7 +668,7 @@ async def test_synchronize_uses_github_action_config_push_commands(monkeypatch, await github_action_runner.run_action() assert handled == [ - ("https://api.github.com/repos/org/repo/pulls/1", "/describe"), + ("https://api.github.com/repos/org/repo/pulls/1", ["/describe"]), ] @@ -575,6 +686,41 @@ async def test_issue_comment_from_user_is_processed(monkeypatch, tmp_path, resto assert handled == [("https://api.github.com/repos/org/repo/pulls/1", "/review")] +@pytest.mark.asyncio +async def test_issue_comment_not_starting_with_slash_is_skipped( + monkeypatch, tmp_path, restore_github_settings +): + """Regression for #3603: a plain comment must not fail the action run.""" + handled = [] + _patch_issue_comment_deps(monkeypatch, handled) + monkeypatch.setenv("GITHUB_EVENT_NAME", "issue_comment") + monkeypatch.setenv( + "GITHUB_EVENT_PATH", + str(_write_issue_comment_event_with_body(tmp_path, "thanks!")), + ) + monkeypatch.setenv("GITHUB_TOKEN", "token") + + await github_action_runner.run_action() + + assert handled == [] # non-command comment skipped; action run stays green + +@pytest.mark.asyncio +async def test_issue_comment_on_plain_issue_is_dispatched(monkeypatch, tmp_path, restore_github_settings): + handled = [] + _patch_issue_comment_deps(monkeypatch, handled) + monkeypatch.setenv("GITHUB_EVENT_NAME", "issue_comment") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_plain_issue_comment_event(tmp_path))) + monkeypatch.setenv("GITHUB_TOKEN", "token") + + await github_action_runner.run_action() + + assert handled == [ + ( + "https://api.github.com/repos/org/repo/issues/1", + "/ask what is this issue about?", + ) + ] + def _write_workflow_run_event(tmp_path, originating_event="pull_request", pull_requests=None, conclusion="success"): if pull_requests is None: pull_requests = [{"url": "https://api.github.com/repos/org/repo/pulls/42", "number": 42}] @@ -723,6 +869,17 @@ def _write_issue_comment_event_with_body(tmp_path, body): })) return event_path +def _write_plain_issue_comment_event(tmp_path): + event_path = tmp_path / "event.json" + event_path.write_text(json.dumps({ + "action": "created", + "comment": {"body": "/ask what is this issue about?", "id": 123}, + "issue": { + "url": "https://api.github.com/repos/org/repo/issues/1", + }, + "sender": {"type": "User"}, + })) + return event_path @pytest.mark.asyncio async def test_issue_comment_body_reaches_the_agent_with_its_case_preserved( diff --git a/tests/unittest/test_github_app_auth.py b/tests/unittest/test_github_app_auth.py index 58e1820953..f3761d7574 100644 --- a/tests/unittest/test_github_app_auth.py +++ b/tests/unittest/test_github_app_auth.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import jwt from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -8,10 +10,12 @@ class TestGithubAppAuth: def test_integer_app_id_produces_valid_jwt(self): - """GitHub App ids are integers in the settings toml, but PyJWT >=2.11 rejects a - non-string `iss` claim and PyGithub 1.59 passes the id through raw (#2955, - previously #2210; fixed upstream in PyGithub#3272, which we don't ship yet). - The provider must cast to str before building the authentication. + """GitHub App ids are integers in the settings toml. PyJWT >=2.11 rejects a + non string `iss` claim and PyGithub 1.59 passed the id through raw (#2955, + previously #2210; upstream fixed the pass through in PyGithub#3272, which + landed in 2.7.0, so the 2.10 pin ships it). The provider keeps casting to + str before building the authentication, which is thus harmless on the 2.10 + pin while it stays a hard requirement for the older 1.59 behaviour. """ key = rsa.generate_private_key(public_exponent=65537, key_size=2048) private_key_pem = key.private_bytes( @@ -46,6 +50,74 @@ def test_integer_app_id_produces_valid_jwt(self): for name, value in original.items(): settings.set(name, value) + def test_client_defaults_to_no_pacing_and_no_retry(self, monkeypatch): + """PyGithub 2.x turns on pacing and 10 retries by default; PR-Agent must + stay behaviour-neutral unless configured. The [github] settings mirror the + 1.59 defaults and the client constructor applies them explicitly + (https://github.com/The-PR-Agent/pr-agent/pull/3240).""" + settings = get_settings() + original = { + "GITHUB.DEPLOYMENT_TYPE": settings.get("GITHUB.DEPLOYMENT_TYPE", None), + "GITHUB.USER_TOKEN": settings.get("GITHUB.USER_TOKEN", None), + } + settings.set("GITHUB.DEPLOYMENT_TYPE", "user") + settings.set("GITHUB.USER_TOKEN", "stub-token") + captured = {} + + def fake_github(**kwargs): + captured.update(kwargs) + return MagicMock() + + monkeypatch.setattr("pr_agent.git_providers.github_provider.Github", fake_github) + provider = GithubProvider.__new__(GithubProvider) + provider.installation_id = None + provider.base_url = "https://api.github.com" + try: + provider._get_github_client() + finally: + for name, value in original.items(): + settings.set(name, value) + + assert captured["seconds_between_requests"] == 0 + assert captured["seconds_between_writes"] == 0 + assert captured["retry"] is None + + def test_pacing_and_retry_settings_reach_the_client(self, monkeypatch): + """Operators may opt into PyGithub 2.x throttling and backoff via the + [github] settings; each knob must land on the constructor (PR #3240).""" + settings = get_settings() + original = { + "GITHUB.DEPLOYMENT_TYPE": settings.get("GITHUB.DEPLOYMENT_TYPE", None), + "GITHUB.USER_TOKEN": settings.get("GITHUB.USER_TOKEN", None), + "GITHUB.SECONDS_BETWEEN_REQUESTS": settings.get("GITHUB.SECONDS_BETWEEN_REQUESTS", None), + "GITHUB.SECONDS_BETWEEN_WRITES": settings.get("GITHUB.SECONDS_BETWEEN_WRITES", None), + "GITHUB.API_RETRIES": settings.get("GITHUB.API_RETRIES", None), + } + settings.set("GITHUB.DEPLOYMENT_TYPE", "user") + settings.set("GITHUB.USER_TOKEN", "stub-token") + settings.set("GITHUB.SECONDS_BETWEEN_REQUESTS", 0.25) + settings.set("GITHUB.SECONDS_BETWEEN_WRITES", 1.0) + settings.set("GITHUB.API_RETRIES", 4) + captured = {} + + def fake_github(**kwargs): + captured.update(kwargs) + return MagicMock() + + monkeypatch.setattr("pr_agent.git_providers.github_provider.Github", fake_github) + provider = GithubProvider.__new__(GithubProvider) + provider.installation_id = None + provider.base_url = "https://api.github.com" + try: + provider._get_github_client() + finally: + for name, value in original.items(): + settings.set(name, value) + + assert captured["seconds_between_requests"] == 0.25 + assert captured["seconds_between_writes"] == 1.0 + assert captured["retry"].total == 4 + def test_is_bot_user_reads_github_section_setting(self): """is_bot_user must honor `ignore_bot_pr` from the [github] section (#3017): configuration.toml sets it under [github], so reading diff --git a/tests/unittest/test_github_incremental_default.py b/tests/unittest/test_github_incremental_default.py index fe707356bc..22dc8a115f 100644 --- a/tests/unittest/test_github_incremental_default.py +++ b/tests/unittest/test_github_incremental_default.py @@ -1,4 +1,6 @@ """The default `IncrementalPR` is per call, not one object shared since import.""" +import datetime +from types import SimpleNamespace from unittest.mock import MagicMock, patch from pr_agent.git_providers.git_provider import IncrementalPR @@ -55,3 +57,105 @@ def test_an_incremental_passed_in_is_the_one_used(): provider.get_incremental_commits(given) assert provider.incremental is given + + +def test_can_run_incremental_review_handles_aware_commit_dates(): + """Regression: PyGithub 2.x returns timezone-aware commit timestamps, + but _can_run_incremental_review compared them against a naive + datetime.now(), raising TypeError on every second run.""" + from pr_agent.tools.pr_reviewer import PRReviewer + + reviewer = PRReviewer.__new__(PRReviewer) + reviewer.is_auto = False + reviewer.git_provider = MagicMock(spec=["get_incremental_commits"]) + reviewer.pr_url = "https://github.com/test/repo/pull/1" + + aware_date = datetime.datetime(2026, 9, 10, 12, 0, tzinfo=datetime.timezone.utc) + commit = SimpleNamespace(commit=SimpleNamespace(author=SimpleNamespace(date=aware_date))) + + reviewer.incremental = IncrementalPR(True) + reviewer.incremental.commits_range = [commit] + reviewer.incremental.last_seen_commit = commit + + with patch("pr_agent.tools.pr_reviewer.get_settings") as mock_settings: + mock_settings.return_value.git_provider = "github" + mock_settings.return_value.pr_reviewer.minimal_commits_for_incremental_review = 0 + mock_settings.return_value.pr_reviewer.minimal_minutes_for_incremental_review = 0 + mock_settings.return_value.pr_reviewer.require_all_thresholds_for_incremental_review = False + # Must not raise TypeError + result = reviewer._can_run_incremental_review() + + assert result in (True, False) # either outcome is valid; crash is the bug + + +def _frozen_now(fixed_aware_utc): + class _FrozenDatetime: + timezone = datetime.timezone + timedelta = datetime.timedelta + + class datetime: + @staticmethod + def now(*args, **kwargs): + return fixed_aware_utc.astimezone(datetime.timezone.utc) + + return patch("pr_agent.tools.pr_reviewer.datetime", new=_FrozenDatetime) + + +def test_incremental_review_runs_when_commit_is_older_than_utc_threshold(): + """The threshold is naive UTC, so a host in a non-UTC zone must not shift + the minimum-age decision.""" + from pr_agent.tools.pr_reviewer import PRReviewer + + reviewer = PRReviewer.__new__(PRReviewer) + reviewer.is_auto = False + reviewer.git_provider = MagicMock(spec=["get_incremental_commits"]) + reviewer.pr_url = "https://github.com/test/repo/pull/1" + + commit = SimpleNamespace( + commit=SimpleNamespace( + author=SimpleNamespace(date=datetime.datetime(2026, 9, 10, 12, 0, tzinfo=datetime.timezone.utc)) + ) + ) + reviewer.incremental = IncrementalPR(True) + reviewer.incremental.commits_range = [commit] + reviewer.incremental.last_seen_commit = commit + + with ( + _frozen_now(datetime.datetime(2026, 9, 10, 13, 0, tzinfo=datetime.timezone.utc)), + patch("pr_agent.tools.pr_reviewer.get_settings") as mock_settings, + ): + mock_settings.return_value.git_provider = "github" + mock_settings.return_value.pr_reviewer.minimal_commits_for_incremental_review = 0 + mock_settings.return_value.pr_reviewer.minimal_minutes_for_incremental_review = 30 + mock_settings.return_value.pr_reviewer.require_all_thresholds_for_incremental_review = False + + assert reviewer._can_run_incremental_review() is True + + +def test_incremental_review_skipped_when_commit_is_newer_than_utc_threshold(): + from pr_agent.tools.pr_reviewer import PRReviewer + + reviewer = PRReviewer.__new__(PRReviewer) + reviewer.is_auto = False + reviewer.git_provider = MagicMock(spec=["get_incremental_commits"]) + reviewer.pr_url = "https://github.com/test/repo/pull/1" + + commit = SimpleNamespace( + commit=SimpleNamespace( + author=SimpleNamespace(date=datetime.datetime(2026, 9, 10, 13, 15, tzinfo=datetime.timezone.utc)) + ) + ) + reviewer.incremental = IncrementalPR(True) + reviewer.incremental.commits_range = [commit] + reviewer.incremental.last_seen_commit = commit + + with ( + _frozen_now(datetime.datetime(2026, 9, 10, 13, 0, tzinfo=datetime.timezone.utc)), + patch("pr_agent.tools.pr_reviewer.get_settings") as mock_settings, + ): + mock_settings.return_value.git_provider = "github" + mock_settings.return_value.pr_reviewer.minimal_commits_for_incremental_review = 0 + mock_settings.return_value.pr_reviewer.minimal_minutes_for_incremental_review = 30 + mock_settings.return_value.pr_reviewer.require_all_thresholds_for_incremental_review = True + + assert reviewer._can_run_incremental_review() is False diff --git a/tests/unittest/test_github_issue_reference_extraction.py b/tests/unittest/test_github_issue_reference_extraction.py index c6e492125d..3e036c03fa 100644 --- a/tests/unittest/test_github_issue_reference_extraction.py +++ b/tests/unittest/test_github_issue_reference_extraction.py @@ -64,3 +64,122 @@ def test_a_full_url_is_not_bounded(): def test_a_cross_repo_shorthand_is_not_bounded(): """Control: owner/repo#123 names its repository, so it is unambiguous too.""" assert _links("Fixes other/project#12345") == [f"{BASE}/other/project/issues/12345"] + + +@pytest.mark.parametrize( + ("shorthand", "expected_url"), + [ + ("my-org/repo#42", f"{BASE}/my-org/repo/issues/42"), + ("org/my-repo#42", f"{BASE}/org/my-repo/issues/42"), + ("my-org/my-repo#42", f"{BASE}/my-org/my-repo/issues/42"), + ], +) +def test_cross_repo_with_hyphens(shorthand, expected_url): + assert _links(f"Fixes {shorthand}") == [expected_url] + + +@pytest.mark.parametrize( + ("shorthand", "expected_url"), + [ + ("org/my.repo#7", f"{BASE}/org/my.repo/issues/7"), + ("org/my_repo#7", f"{BASE}/org/my_repo/issues/7"), + ], +) +def test_cross_repo_with_period_and_underscore_in_repo_name(shorthand, expected_url): + assert _links(f"Fixes {shorthand}") == [expected_url] + + +def test_cross_repo_this_repo_own_name(): + assert _links("Fixes The-PR-Agent/pr-agent#3081") == [f"{BASE}/The-PR-Agent/pr-agent/issues/3081"] + + +def test_regression_bare_shorthand_resolves_against_current_repo(): + assert _links("Fixes #42") == [f"{BASE}/{REPO}/issues/42"] + + +def test_regression_full_url_still_wins(): + url = f"{BASE}/some-org/some-repo/issues/99" + assert _links(f"Fixes {url}") == [url] + + +def test_regression_mid_token_does_not_match_cross_repo(): + assert _links("see x/my-org/my-repo#42") == [f"{BASE}/{REPO}/issues/42"] + assert _links("see x/other/project#12345") == [f"{BASE}/{REPO}/issues/12345"] + + +@pytest.fixture +def description_regex(monkeypatch): + def configure(pattern): + monkeypatch.setattr( + "pr_agent.tools.ticket_pr_compliance_check.get_settings", + lambda: {"config.description_issue_regex": pattern}, + ) + return configure + + +def test_custom_syntax_ignores_incidental_pr_reference(description_regex): + description_regex(r"(?i)(?:fixes|closes|resolves|refs|ticket)\s*[:#]?\s*#?(\d+)") + assert _links("The fix landed in PR #56. Fixes #123; ticket: 456") == [ + f"{BASE}/{REPO}/issues/123", f"{BASE}/{REPO}/issues/456", + ] + + +def test_empty_custom_pattern_preserves_default(description_regex): + description_regex("") + assert _links("The fix landed in PR #56") == [f"{BASE}/{REPO}/issues/56"] + + +@pytest.mark.parametrize("pattern", ["[", r"#\d+", r"(fixes) #(\d+)", 42, r"(a){4294967296}", "(" * 1000]) +def test_invalid_custom_pattern_falls_back(description_regex, pattern, monkeypatch): + from unittest.mock import Mock + + logger = Mock() + monkeypatch.setattr("pr_agent.tools.ticket_pr_compliance_check.get_logger", lambda: logger) + description_regex(pattern) + assert _links("PR #56") == [f"{BASE}/{REPO}/issues/56"] + logger.warning.assert_called_once() + + +@pytest.mark.parametrize("number", ["9" * 4301, "²", "١٢"]) +def test_invalid_numeric_capture_preserves_valid_references(description_regex, number): + description_regex(r"ticket: (\S+)") + assert _links(f"ticket: {number} ticket: 42") == [f"{BASE}/{REPO}/issues/42"] + + +def test_custom_matches_keep_explicit_references_order_and_cap(description_regex): + description_regex(r"ticket: (\d+)") + assert _links("ticket: 1 other/project#2 ticket: 1 https://github.com/org/repo/issues/3 ticket: 4") == [ + f"{BASE}/{REPO}/issues/1", f"{BASE}/other/project/issues/2", f"{BASE}/{REPO}/issues/3", + ] + + +def test_custom_pattern_does_not_duplicate_explicit_numbers(description_regex): + description_regex(r"(\d+)") + assert _links("other/project#42 https://github.com/elsewhere/project/issues/99") == [ + f"{BASE}/other/project/issues/42", f"{BASE}/elsewhere/project/issues/99", + ] + + +def test_custom_pattern_controls_digit_bound(description_regex): + description_regex(r"ticket: (\d+)") + assert _links("ticket: 1234567") == [f"{BASE}/{REPO}/issues/1234567"] + + +@pytest.mark.parametrize("description", ["ticket:", "ticket: abc"]) +def test_custom_capture_must_be_present_and_numeric(description_regex, description): + description_regex(r"ticket:(?: (\w+))?") + assert _links(description) == [] + + +def test_custom_pattern_without_repo_preserves_explicit_links(description_regex): + description_regex(r"ticket: (\d+)") + assert extract_ticket_links_from_pr_description("ticket: 1 other/project#2", "") == [ + f"{BASE}/other/project/issues/2", + ] + + +def test_custom_pattern_respects_enterprise_base_url(description_regex): + description_regex(r"ticket: (\d+)") + assert extract_ticket_links_from_pr_description("ticket: 42", REPO, "https://github.example.com/") == [ + f"https://github.example.com/{REPO}/issues/42", + ] diff --git a/tests/unittest/test_github_polling.py b/tests/unittest/test_github_polling.py index ffa135ed9c..f5c3390e85 100644 --- a/tests/unittest/test_github_polling.py +++ b/tests/unittest/test_github_polling.py @@ -1,27 +1,476 @@ +import asyncio +import multiprocessing +import random +import time from collections import deque +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest from pr_agent.servers import github_polling -def test_start_queued_processes_respects_parallel_limit(monkeypatch): - created_processes = [] +@pytest.fixture +def workers(monkeypatch): + created = [] class FakeProcess: def __init__(self, target, args): self.target = target self.args = args - self.started = False - created_processes.append(self) + self.pid = None + self.alive = False + self.closed = False + self.joined = False + created.append(self) def start(self): - self.started = True + self.pid = len(created) + self.alive = True + assert sum(p.alive for p in created) <= 10 + + def is_alive(self): + assert not self.closed + return self.alive + + def join(self, timeout): + assert timeout == 0 + assert not self.alive + self.joined = True + + def close(self): + assert not self.alive + self.closed = True + + # Replace the module reference, not multiprocessing.Process process-wide. + monkeypatch.setattr(github_polling, "multiprocessing", SimpleNamespace(Process=FakeProcess)) + monkeypatch.setattr(github_polling, "get_logger", MagicMock()) + return created, FakeProcess + + +def _queue(size): + return deque((time.sleep, (i,)) for i in range(size)) + + +@pytest.mark.asyncio +async def test_start_queued_processes_respects_parallel_limit(workers): + created, _ = workers + active = [] + queue = _queue(12) + await github_polling._start_queued_processes(queue, 10, active) + assert len(created) == len(active) == 10 + assert all(process.alive for process in active) + assert [process.args for process in active] == [(i,) for i in range(10)] + assert not queue + github_polling.get_logger().info.assert_not_called() + + +@pytest.mark.asyncio +async def test_next_batch_waits_without_dropping_accepted_work(workers, monkeypatch): + created, _ = workers + active = [] + await github_polling._start_queued_processes(_queue(10), 10, active) + waiting = asyncio.Event() + release = asyncio.Event() + + async def wait_for_capacity(delay): + assert delay == 0.25 + waiting.set() + await release.wait() + + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=wait_for_capacity)) + queue = _queue(3) + task = asyncio.create_task(github_polling._start_queued_processes(queue, 10, active)) + try: + await asyncio.wait_for(waiting.wait(), 2) + assert len(created) == 10 + assert len(queue) == 3 + github_polling.get_logger().info.assert_called_once_with( + "Polling dispatch waiting for capacity: 10 workers active, 3 tasks queued" + ) + for process in created[:3]: + process.alive = False + release.set() + await asyncio.wait_for(task, 2) + assert len(created) == 13 + assert len(active) == 10 + assert all(p.joined and p.closed for p in created[:3]) + assert not queue + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_capacity_wait_logs_once_per_batch(workers, monkeypatch): + active = [] + await github_polling._start_queued_processes(_queue(10), 10, active) + info = github_polling.get_logger().info + info.assert_not_called() + capacity_checks = 0 + + async def release_capacity(delay): + nonlocal capacity_checks + assert delay == github_polling.POLLING_CAPACITY_CHECK_INTERVAL + capacity_checks += 1 + if capacity_checks % 3 == 0: + active[0].alive = False + + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=release_capacity)) + for batch in range(2): + await github_polling._start_queued_processes(_queue(2), 10, active) + assert info.call_count == batch + 1 + info.assert_called_with("Polling dispatch waiting for capacity: 10 workers active, 2 tasks queued") + assert capacity_checks == 12 + await github_polling._start_queued_processes(_queue(0), 10, active) + assert info.call_count == 2 + + +@pytest.mark.asyncio +async def test_cancelled_capacity_wait_does_not_start_or_discard_work(workers, monkeypatch): + created, _ = workers + active = [] + await github_polling._start_queued_processes(_queue(10), 10, active) + entered = asyncio.Event() + + async def wait_for_capacity(delay): + entered.set() + await asyncio.Event().wait() + + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=wait_for_capacity)) + queue = _queue(2) + task = asyncio.create_task(github_polling._start_queued_processes(queue, 10, active)) + try: + await asyncio.wait_for(entered.wait(), 2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + _ = await task + assert len(created) == len(active) == 10 + assert len(queue) == 2 + github_polling.get_logger().error.assert_called_once_with( + "Polling dispatch stopped with 2 tasks not dispatched" + ) + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("partial_start", [False, True]) +async def test_start_failure_keeps_previous_workers_tracked(workers, monkeypatch, partial_start): + created, process_type = workers + start = process_type.start + + def fail_second(process): + if len(created) == 2: + if partial_start: + start(process) + raise OSError("Cannot start worker") + start(process) + + monkeypatch.setattr(process_type, "start", fail_second) + active = [] + queue = _queue(3) + with pytest.raises(github_polling._PollingWorkerStartError, match="startup failed") as failure: + await github_polling._start_queued_processes(queue, 10, active) + assert isinstance(failure.value.__cause__, OSError) + assert active == (created if partial_start else created[:1]) + assert created[0].alive + assert created[1].closed is (not partial_start) + expected_remaining = 1 if partial_start else 2 + assert len(queue) == expected_remaining + github_polling.get_logger().error.assert_called_once_with( + f"Polling dispatch stopped with {expected_remaining} tasks not dispatched" + ) + assert len(created) == 2 + + +@pytest.mark.asyncio +async def test_many_batches_keep_the_active_limit(workers, monkeypatch): + created, _ = workers + active = [] + rng = random.Random(42) + accepted = 0 + + async def complete_workers(delay): + for process in active[:3]: + process.alive = False + + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=complete_workers)) + for _ in range(100): + size = rng.randint(1, 12) + accepted += min(size, 10) + await github_polling._start_queued_processes(_queue(size), 10, active) + assert len(active) <= 10 + for process in active[:rng.randint(0, len(active))]: + process.alive = False + assert len(created) == accepted + for process in active: + process.alive = False + github_polling._reap_finished_processes(active) + assert not active + assert all(p.joined and p.closed for p in created) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("idle_response", [304, 200, 500, OSError("poll failed")]) +async def test_polling_loop_reaps_workers_on_idle_and_failed_polls(workers, monkeypatch, idle_response): + created, _ = workers + settings = SimpleNamespace( + github=SimpleNamespace(deployment_type="user", user_token="test-token"), set=MagicMock() + ) + monkeypatch.setattr(github_polling, "get_settings", lambda: settings) + monkeypatch.setattr( + github_polling, + "get_git_provider", + lambda: lambda: SimpleNamespace(get_user_id=lambda: "bot"), + ) + monkeypatch.setattr(github_polling, "mark_notification_as_read", AsyncMock()) + monkeypatch.setattr(github_polling, "is_valid_notification", AsyncMock(return_value=( + True, set(), {"id": 2}, "@bot /review", "https://example.test/pull/1", "@bot" + ))) + finished = asyncio.Event() + polls = 0 + sleeps = 0 + + class Response: + def __init__(self, status): + self.status = status + self.headers = {} + + async def __aenter__(self): + if isinstance(self.status, Exception): + raise self.status + return self + + async def __aexit__(self, *args): + pass + + async def json(self): + return [{"id": 1}] if polls == 1 else [] + + class Session: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def get(self, *args, **kwargs): + nonlocal polls + polls += 1 + return Response(200 if polls == 1 else idle_response) + + async def sleep(delay): + nonlocal sleeps + sleeps += 1 + if sleeps == 2: + created[0].alive = False + if sleeps == 3: + finished.set() + await asyncio.Event().wait() + + monkeypatch.setattr(github_polling, "aiohttp", SimpleNamespace(ClientSession=Session)) + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=sleep)) + task = asyncio.create_task(github_polling.polling_loop()) + try: + await asyncio.wait_for(finished.wait(), 2) + assert len(created) == 1 + assert created[0].joined and created[0].closed + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_reap_real_spawned_worker(monkeypatch): + ctx = multiprocessing.get_context("spawn") + monkeypatch.setattr(github_polling, "multiprocessing", SimpleNamespace(Process=ctx.Process)) + active = [] + await github_polling._start_queued_processes(deque([(time.sleep, (0,))]), 1, active) + process = active[0] + try: + await asyncio.to_thread(process.join, 5) + assert not process.is_alive() + github_polling._reap_finished_processes(active) + assert not active + with pytest.raises(ValueError, match="closed"): + process.is_alive() + finally: + if active: + if process.is_alive(): + process.terminate() + process.join(timeout=5) + process.close() + + +@pytest.mark.asyncio +async def test_polling_loop_shares_capacity_between_batches(workers, monkeypatch): + created, _ = workers + settings = SimpleNamespace( + github=SimpleNamespace(deployment_type="user", user_token="test-token"), set=MagicMock() + ) + monkeypatch.setattr(github_polling, "get_settings", lambda: settings) + monkeypatch.setattr( + github_polling, + "get_git_provider", + lambda: lambda: SimpleNamespace(get_user_id=lambda: "bot"), + ) + monkeypatch.setattr(github_polling, "mark_notification_as_read", AsyncMock()) + monkeypatch.setattr(github_polling, "is_valid_notification", AsyncMock(return_value=( + True, set(), {"id": 2}, "@bot /review", "https://example.test/pull/1", "@bot" + ))) + waiting = asyncio.Event() + release = asyncio.Event() + finished = asyncio.Event() + polls = 0 + + class Session: + status = 200 + headers = {} + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def get(self, *args, **kwargs): + nonlocal polls + polls += 1 + return self + + async def json(self): + return [{"id": i} for i in range(10 if polls == 1 else 2)] + + async def sleep(delay): + if delay == 0.25: + waiting.set() + await release.wait() + elif polls == 2: + finished.set() + await asyncio.Event().wait() + + monkeypatch.setattr(github_polling, "aiohttp", SimpleNamespace(ClientSession=Session)) + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=sleep)) + task = asyncio.create_task(github_polling.polling_loop()) + try: + await asyncio.wait_for(waiting.wait(), 2) + assert len(created) == 10 + assert polls == 2 + for process in created[:2]: + process.alive = False + release.set() + await asyncio.wait_for(finished.wait(), 2) + assert len(created) == 12 + assert sum(process.alive for process in created) == 10 + finally: + task.cancel() + result, = await asyncio.gather(task, return_exceptions=True) + assert isinstance(result, asyncio.CancelledError) + + +@pytest.mark.asyncio +async def test_polling_loop_logs_accepted_work_cancelled_before_dispatch(workers, monkeypatch): + created, _ = workers + settings = SimpleNamespace( + github=SimpleNamespace(deployment_type="user", user_token="test-token"), set=MagicMock() + ) + monkeypatch.setattr(github_polling, "get_settings", lambda: settings) + monkeypatch.setattr( + github_polling, + "get_git_provider", + lambda: lambda: SimpleNamespace(get_user_id=lambda: "bot"), + ) + monkeypatch.setattr(github_polling, "mark_notification_as_read", AsyncMock()) + entered_second = asyncio.Event() + validation_calls = 0 + + async def validate(*args, **kwargs): + nonlocal validation_calls + validation_calls += 1 + if validation_calls == 1: + return True, set(), {"id": 2}, "@bot /review", "https://example.test/pull/1", "@bot" + entered_second.set() + await asyncio.Event().wait() + raise AssertionError("Second validation should remain blocked") + + monkeypatch.setattr(github_polling, "is_valid_notification", validate) + + class Session: + status = 200 + headers = {} + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def get(self, *args, **kwargs): + return self + + async def json(self): + return [{"id": 1}, {"id": 2}] + + async def sleep(delay): + await asyncio.sleep(0) + + monkeypatch.setattr(github_polling, "aiohttp", SimpleNamespace(ClientSession=Session)) + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=sleep)) + task = asyncio.create_task(github_polling.polling_loop()) + await asyncio.wait_for(entered_second.wait(), 2) + task.cancel() + result, = await asyncio.gather(task, return_exceptions=True) + + assert isinstance(result, asyncio.CancelledError) + assert not created + github_polling.get_logger().error.assert_any_call( + "Polling dispatch stopped with 1 tasks not dispatched" + ) + + +@pytest.mark.asyncio +async def test_polling_loop_stops_after_worker_start_failure(workers, monkeypatch): + _, process_type = workers + settings = SimpleNamespace( + github=SimpleNamespace(deployment_type="user", user_token="test-token"), set=MagicMock() + ) + monkeypatch.setattr(github_polling, "get_settings", lambda: settings) + monkeypatch.setattr( + github_polling, + "get_git_provider", + lambda: lambda: SimpleNamespace(get_user_id=lambda: "bot"), + ) + monkeypatch.setattr(github_polling, "mark_notification_as_read", AsyncMock()) + monkeypatch.setattr(github_polling, "is_valid_notification", AsyncMock(return_value=( + True, set(), {"id": 2}, "@bot /review", "https://example.test/pull/1", "@bot" + ))) + start = MagicMock(side_effect=OSError("Cannot start worker")) + monkeypatch.setattr(process_type, "start", start) + + class Session: + status = 200 + headers = {} + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def get(self, *args, **kwargs): + return self - monkeypatch.setattr(github_polling.multiprocessing, "Process", FakeProcess) + async def json(self): + return [{"id": 1}] - task_queue = deque((lambda: None, ()) for _ in range(12)) - processes = github_polling._start_queued_processes(task_queue, 10) + async def sleep(delay): + await asyncio.sleep(0) - assert len(created_processes) == 10 - assert len(processes) == 10 - assert all(process.started for process in processes) - assert not task_queue + monkeypatch.setattr(github_polling, "aiohttp", SimpleNamespace(ClientSession=Session)) + monkeypatch.setattr(github_polling, "asyncio", SimpleNamespace(sleep=sleep)) + with pytest.raises(github_polling._PollingWorkerStartError, match="startup failed"): + await asyncio.wait_for(github_polling.polling_loop(), timeout=2) + start.assert_called_once() diff --git a/tests/unittest/test_github_polling_settings.py b/tests/unittest/test_github_polling_settings.py new file mode 100644 index 0000000000..1d109cabed --- /dev/null +++ b/tests/unittest/test_github_polling_settings.py @@ -0,0 +1,125 @@ +import copy +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from pr_agent.config_loader import get_settings, global_settings +from pr_agent.servers import github_polling + + +@pytest.fixture(autouse=True) +def fresh_global_settings(): + """Restore global_settings afterwards so a failing isolation test cannot + pollute the rest of the suite with the very leak it is asserting against.""" + snapshot = copy.deepcopy(global_settings.as_dict()) + yield + for section in set(global_settings.as_dict().keys()) - set(snapshot.keys()): + global_settings.unset(section) + for section, contents in snapshot.items(): + global_settings.unset(section) + global_settings.set(section, copy.deepcopy(contents), merge=False) + + +def test_process_comment_sync_scopes_settings_per_comment(): + captured = {} + publish_progress_before = global_settings.get("CONFIG.PUBLISH_OUTPUT_PROGRESS") + describe_as_comment_before = global_settings.get("pr_description.publish_description_as_comment") + + # Patch one level below run_handle_request so the real asyncio.run path + # executes and the test pins that the contextvar survives it. + async def fake_async_handle_request(pr_url, rest_of_comment, comment_id, git_provider): + settings = get_settings() + captured["settings"] = settings + captured["publish_progress"] = settings.get("CONFIG.PUBLISH_OUTPUT_PROGRESS") + captured["describe_as_comment"] = settings.get("pr_description.publish_description_as_comment") + # Simulate what apply_repo_settings does mid-run: mutate the active settings. + settings.set("config.leaky_test_key", "from-another-pr") + return True + + with ( + patch.object(github_polling, "get_git_provider", return_value=MagicMock()), + patch.object(github_polling, "async_handle_request", side_effect=fake_async_handle_request), + patch.object(github_polling, "litellm_callbacks_registered", return_value=False), + ): + github_polling.process_comment_sync("https://example/pr/1", "/review", 123) + + assert captured["settings"] is not global_settings + assert captured["publish_progress"] is False + assert captured["describe_as_comment"] is True + assert global_settings.get("config.leaky_test_key", None) is None + assert global_settings.get("CONFIG.PUBLISH_OUTPUT_PROGRESS") == publish_progress_before + assert global_settings.get("pr_description.publish_description_as_comment") == describe_as_comment_before + + +@pytest.mark.asyncio +async def test_process_comment_scopes_settings_per_comment(): + captured = {} + + async def fake_handle_request(pr_url, request, notify=None): + settings = get_settings() + captured["settings"] = settings + captured["publish_progress"] = settings.get("CONFIG.PUBLISH_OUTPUT_PROGRESS") + settings.set("config.leaky_test_key_async", "from-another-pr") + return True + + agent = MagicMock() + agent.handle_request = AsyncMock(side_effect=fake_handle_request) + + with ( + patch.object(github_polling, "get_git_provider", return_value=MagicMock()), + patch.object(github_polling, "PRAgent", return_value=agent), + ): + await github_polling.process_comment("https://example/pr/2", "/describe", 456) + + agent.handle_request.assert_awaited_once() + assert captured["settings"] is not global_settings + assert captured["publish_progress"] is False + assert global_settings.get("config.leaky_test_key_async", None) is None + + +def test_consecutive_comments_do_not_share_state(): + tickets_before = global_settings.get("related_tickets", None) + seen = [] + + async def fake_async_handle_request(pr_url, rest_of_comment, comment_id, git_provider): + settings = get_settings() + seen.append(settings.get("related_tickets", None)) + settings.set("related_tickets", [{"ticket_id": 1, "pr": pr_url}]) + return True + + with ( + patch.object(github_polling, "get_git_provider", return_value=MagicMock()), + patch.object(github_polling, "async_handle_request", side_effect=fake_async_handle_request), + patch.object(github_polling, "litellm_callbacks_registered", return_value=False), + ): + github_polling.process_comment_sync("https://example/pr/1", "/review", 1) + github_polling.process_comment_sync("https://example/pr/2", "/review", 2) + + # Neither comment saw the other's tickets, and the base settings kept their prior value. + assert seen == [tickets_before, tickets_before] + assert global_settings.get("related_tickets", None) == tickets_before + + +@pytest.mark.asyncio +async def test_failed_comment_does_not_pin_settings_on_the_calling_context(): + # request_cycle_context has no try/finally around its yield, so an exception + # crossing the with-body would skip the ContextVar reset and leave this + # comment's settings active for everything that runs later in the task. + settings_before = get_settings() + + with patch.object(github_polling, "get_git_provider", side_effect=ValueError("bad PR url")): + await github_polling.process_comment("https://example/pr/3", "/review", 789) + + assert get_settings() is settings_before + + +def test_cancellation_does_not_pin_settings_on_the_calling_context(): + # BaseException is not caught by the handlers' except Exception, so it must + # still leave the scope through _polling_settings_scope's finally. + settings_before = get_settings() + + with patch.object(github_polling, "get_git_provider", side_effect=KeyboardInterrupt): + with pytest.raises(KeyboardInterrupt): + github_polling.process_comment_sync("https://example/pr/4", "/review", 999) + + assert get_settings() is settings_before diff --git a/tests/unittest/test_github_provider_check_runs.py b/tests/unittest/test_github_provider_check_runs.py index 0c140472eb..124d9957a7 100644 --- a/tests/unittest/test_github_provider_check_runs.py +++ b/tests/unittest/test_github_provider_check_runs.py @@ -32,6 +32,7 @@ def _make_provider(last_commit_sha="deadbeef", requester=None): p.pr = SimpleNamespace(_requester=requester or _FakeRequester()) p.last_commit_id = SimpleNamespace(sha=last_commit_sha) if last_commit_sha else None p._check_run_ids = {} + p._check_runs_in_progress = set() return p diff --git a/tests/unittest/test_github_provider_comments.py b/tests/unittest/test_github_provider_comments.py index 8b686acb58..299ffbe280 100644 --- a/tests/unittest/test_github_provider_comments.py +++ b/tests/unittest/test_github_provider_comments.py @@ -33,9 +33,9 @@ def __init__(self, raise_on_first=None): self._raise_on_first = raise_on_first self._calls = 0 - def create_review(self, commit=None, comments=None): + def create_review(self, commit=None, event=None, comments=None): self._calls += 1 - self.create_review_calls.append({"commit": commit, "comments": comments}) + self.create_review_calls.append({"commit": commit, "event": event, "comments": comments}) if self._raise_on_first is not None and self._calls == 1: exc = self._raise_on_first self._raise_on_first = None @@ -55,6 +55,30 @@ def _make_provider(pr=None, max_chars=65000): return p +@pytest.mark.parametrize( + ("output", "max_chars", "expected"), + [ + pytest.param("short", 10, "short", id="short"), + pytest.param("exact", 5, "exact", id="exact"), + pytest.param("x" * 20, 10, ("x" * 7) + "...", id="truncated"), + pytest.param("abcdef", -1, "", id="negative-limit"), + pytest.param("abcdef", 0, "", id="zero-limit"), + pytest.param("abcdef", 1, ".", id="one-character-limit"), + pytest.param("abcdef", 2, "..", id="two-character-limit"), + pytest.param("abcdef", 3, "...", id="three-character-limit"), + pytest.param("😀" * 5, 4, "😀...", id="unicode-code-points"), + ], +) +def test_limit_output_characters_respects_total_limit(output, max_chars, expected): + provider = _make_provider() + + result = provider.limit_output_characters(output, max_chars) + + assert result == expected + if max_chars >= 0: + assert len(result) <= max_chars + + def test_edit_comment_returns_false_on_github_failure(): provider = _make_provider() comment = MagicMock() @@ -172,8 +196,8 @@ def test_create_inline_comment_limits_body_length(monkeypatch): payload = provider.create_inline_comment(long_body, "f.py", "line") assert payload["body"].endswith("...") - # limit_output_characters: output[:max_chars] + '...' - assert payload["body"] == "A" * 10 + "..." + assert payload["body"] == "A" * 7 + "..." + assert len(payload["body"]) == provider.max_comment_chars def test_create_inline_comment_does_not_truncate_short_body(monkeypatch): @@ -312,6 +336,7 @@ def test_publish_code_suggestions_multi_line_payload_shape(): def capture(comments, disable_fallback=False): captured["comments"] = comments + return True provider.publish_inline_comments = capture @@ -416,6 +441,209 @@ def boom(comments, disable_fallback=False): assert result is False +def test_publish_code_suggestions_422_fallback_all_dropped_returns_false(monkeypatch): + """Regression test for #3223: When the 422 fallback drops all comments + (0 verified, 0 repaired), publish_code_suggestions must return False so caller + can trigger retry logic.""" + fake_pr = _FakePR(raise_on_first=_FakeGithubException(status=422)) + provider = _make_provider(pr=fake_pr) + _stub_validation_passthrough(provider) + + # All comments are rejected during verification + monkeypatch.setattr( + provider, + "_verify_code_comments", + lambda comments: ([], [(c, Exception("invalid")) for c in comments]), + ) + # No invalid comment can be repaired + monkeypatch.setattr( + provider, + "_try_fix_invalid_inline_comments", + lambda invalid_list: [], + ) + + suggestions = [{ + "body": "```suggestion\nsuggestion\n```", + "relevant_file": "src/foo.py", + "relevant_lines_start": 10, + "relevant_lines_end": 12, + }] + + result = provider.publish_code_suggestions(suggestions) + assert result is False + # Only the initial failing create_review call occurred; 0 fallback comments posted + assert len(fake_pr.create_review_calls) == 1 + + +def test_publish_code_suggestions_422_fallback_partial_success_returns_true(monkeypatch): + """When 422 fallback successfully publishes at least one comment, return True + to prevent duplicate comment creation by whole-batch retries.""" + fake_pr = _FakePR(raise_on_first=_FakeGithubException(status=422)) + provider = _make_provider(pr=fake_pr) + _stub_validation_passthrough(provider) + + # 1 verified comment, 1 invalid comment + def fake_verify(comments): + return [comments[0]], [(comments[1], Exception("invalid"))] + + monkeypatch.setattr(provider, "_verify_code_comments", fake_verify) + monkeypatch.setattr(provider, "_try_fix_invalid_inline_comments", lambda invalid_list: []) + + suggestions = [ + { + "body": "```suggestion\nfirst\n```", + "relevant_file": "src/foo.py", + "relevant_lines_start": 1, + "relevant_lines_end": 2, + }, + { + "body": "```suggestion\nsecond\n```", + "relevant_file": "src/foo.py", + "relevant_lines_start": 5, + "relevant_lines_end": 6, + }, + ] + + result = provider.publish_code_suggestions(suggestions) + assert result is True + # 1 initial failed batch call, 1 successful fallback call with the verified comment + assert len(fake_pr.create_review_calls) == 2 + assert len(fake_pr.create_review_calls[1]["comments"]) == 1 + + +def test_publish_code_suggestions_422_fallback_repaired_comment_success(monkeypatch): + """When initial batch gets 422, verification rejects, but repairing succeeds and + individual publish succeeds, return True.""" + fake_pr = _FakePR(raise_on_first=_FakeGithubException(status=422)) + provider = _make_provider(pr=fake_pr) + _stub_validation_passthrough(provider) + + settings = SimpleNamespace( + github=SimpleNamespace(try_fix_invalid_inline_comments=True), + get=lambda key, default=None: default, + ) + monkeypatch.setattr(gh_module, "get_settings", lambda: settings) + + monkeypatch.setattr( + provider, + "_verify_code_comments", + lambda comments: ([], [(comments[0], Exception("invalid"))]), + ) + repaired = [{"body": "fixed single line", "path": "src/foo.py", "line": 10, "side": "RIGHT"}] + monkeypatch.setattr(provider, "_try_fix_invalid_inline_comments", lambda invalid: repaired) + + suggestions = [{ + "body": "```suggestion\nmulti\nline\n```", + "relevant_file": "src/foo.py", + "relevant_lines_start": 10, + "relevant_lines_end": 12, + }] + + result = provider.publish_code_suggestions(suggestions) + assert result is True + # Call 1: initial batch -> raises 422 + # Call 2: repaired comment via publish_inline_comments([comment], disable_fallback=True) -> succeeds + assert len(fake_pr.create_review_calls) == 2 + assert fake_pr.create_review_calls[1]["comments"] == repaired + + +def test_publish_code_suggestions_422_fallback_repaired_comment_failure_returns_false(monkeypatch): + """When repaired payload is generated but publishing that repaired comment fails, + it must NOT count as published, and publish_code_suggestions must return False.""" + fake_pr = _FakePR(raise_on_first=_FakeGithubException(status=422)) + provider = _make_provider(pr=fake_pr) + _stub_validation_passthrough(provider) + + settings = SimpleNamespace( + github=SimpleNamespace(try_fix_invalid_inline_comments=True), + get=lambda key, default=None: default, + ) + monkeypatch.setattr(gh_module, "get_settings", lambda: settings) + + monkeypatch.setattr( + provider, + "_verify_code_comments", + lambda comments: ([], [(comments[0], Exception("invalid"))]), + ) + repaired = [{"body": "fixed single line", "path": "src/foo.py", "line": 10, "side": "RIGHT"}] + monkeypatch.setattr(provider, "_try_fix_invalid_inline_comments", lambda invalid: repaired) + + calls = 0 + + def fail_repaired(commit=None, event=None, comments=None): + nonlocal calls + calls += 1 + if calls == 1: + raise _FakeGithubException(status=422) + # with disable_fallback=True, this re-raises from publish_inline_comments + raise _FakeGithubException(status=422) + + fake_pr.create_review = fail_repaired + + suggestions = [{ + "body": "```suggestion\nmulti\nline\n```", + "relevant_file": "src/foo.py", + "relevant_lines_start": 10, + "relevant_lines_end": 12, + }] + + result = provider.publish_code_suggestions(suggestions) + assert result is False + assert calls == 2 + + +def test_publish_code_suggestions_normal_success(): + """Clean create_review call without 422 must return True.""" + fake_pr = _FakePR() + provider = _make_provider(pr=fake_pr) + _stub_validation_passthrough(provider) + + suggestions = [{ + "body": "normal", + "relevant_file": "src/foo.py", + "relevant_lines_start": 1, + "relevant_lines_end": 1, + }] + + result = provider.publish_code_suggestions(suggestions) + assert result is True + assert len(fake_pr.create_review_calls) == 1 + + +def test_persistent_dedup_all_skipped_returns_true(monkeypatch): + """When persistent_inline_comments is enabled and all comments are duplicates, + publish_inline_comments and publish_code_suggestions must return True without + calling create_review.""" + fake_pr = _FakePR() + provider = _make_provider(pr=fake_pr) + _stub_validation_passthrough(provider) + + settings = SimpleNamespace( + get=lambda key, default=None: True if key == "config.persistent_inline_comments" else default, + github=SimpleNamespace(try_fix_invalid_inline_comments=False), + ) + monkeypatch.setattr(gh_module, "get_settings", lambda: settings) + + store = MagicMock() + store.seen.return_value = True + monkeypatch.setattr(gh_module, "get_inline_comment_store", lambda prov: store) + + comments = [{"path": "src/foo.py", "body": "already posted", "line": 5}] + res_inline = provider.publish_inline_comments(comments) + assert res_inline is True + assert len(fake_pr.create_review_calls) == 0 + + suggestions = [{ + "body": "already posted", + "relevant_file": "src/foo.py", + "relevant_lines_start": 5, + "relevant_lines_end": 5, + }] + res_suggestions = provider.publish_code_suggestions(suggestions) + assert res_suggestions is True + assert len(fake_pr.create_review_calls) == 0 + + # --------------------------------------------------------------------------- # resolve_comment_thread # --------------------------------------------------------------------------- @@ -702,3 +930,34 @@ def test_user_comment_authorship_resolves_authenticated_user(): assert provider.supports_review_finding_state() is True assert provider.is_comment_authored_by_pr_agent(comment) is True + + +def test_validate_comments_inside_hunks_preserves_backslashes_in_fallback_diff(): + provider = _make_provider() + provider.get_diff_files = lambda: [ + SimpleNamespace( + filename="src/example.py", + patch="@@ -10,2 +10,2 @@\n-old\n+new", + language="python", + ) + ] + suggestion = { + "body": '**Suggestion:** preserve escapes\n```suggestion\npattern = r"\\1\\n\\\\x"\n```', + "relevant_file": "src/example.py", + "relevant_lines_start": 9, + "relevant_lines_end": 11, + "original_suggestion": { + "existing_code": 'pattern = r"\\1"', + "improved_code": 'pattern = r"\\1\\n\\\\x"', + }, + } + + validated = provider.validate_comments_inside_hunks([suggestion]) + result = validated[0] + + assert result["relevant_lines_start"] == 10 + assert result["relevant_lines_end"] == 11 + assert "```suggestion" not in result["body"] + assert "```diff" in result["body"] + assert r'-pattern = r"\1"' in result["body"] + assert r'+pattern = r"\1\n\\x"' in result["body"] diff --git a/tests/unittest/test_github_provider_file_content.py b/tests/unittest/test_github_provider_file_content.py new file mode 100644 index 0000000000..c83ee7de67 --- /dev/null +++ b/tests/unittest/test_github_provider_file_content.py @@ -0,0 +1,74 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from github import GithubException + +from pr_agent.git_providers.github_provider import GithubProvider + + +def _provider_with_result(result=None, error=None): + provider = GithubProvider.__new__(GithubProvider) + repo = MagicMock() + if error is not None: + repo.get_contents.side_effect = error + else: + repo.get_contents.return_value = SimpleNamespace(decoded_content=result) + provider._get_repo = MagicMock(return_value=repo) + return provider + + +def test_get_pr_file_content_keeps_default_leniency(): + provider_error = GithubException(500, {"message": "upstream failure"}, {}) + provider = _provider_with_result(error=provider_error) + + assert provider.get_pr_file_content("CHANGELOG.md", "main") == "" + + +def test_get_pr_file_content_strict_missing_file_is_empty(): + not_found = GithubException(404, {"message": "Not Found"}, {}) + provider = _provider_with_result(error=not_found) + + assert provider.get_pr_file_content("CHANGELOG.md", "main", propagate_errors=True) == "" + + +@pytest.mark.parametrize( + "error", + [ + GithubException(500, {"message": "upstream failure"}, {}), + UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"), + RuntimeError("transport failed"), + ], +) +def test_get_pr_file_content_strict_reraises_original_error(error): + provider = _provider_with_result(error=error) + + with pytest.raises(type(error)) as exc_info: + provider.get_pr_file_content("CHANGELOG.md", "main", propagate_errors=True) + + assert exc_info.value is error + + +def test_create_or_update_pr_file_returns_written_commit(): + provider = GithubProvider.__new__(GithubProvider) + provider.repo_obj = MagicMock() + provider.repo_obj.get_contents.return_value.sha = "file-sha" + provider._get_repo = MagicMock(return_value=provider.repo_obj) + written_commit = object() + provider.repo_obj.update_file.return_value = {"content": object(), "commit": written_commit} + + result = provider.create_or_update_pr_file( + file_path="CHANGELOG.md", + branch="feature-branch", + contents="new content", + message="Update CHANGELOG.md", + ) + + assert result is written_commit + provider.repo_obj.update_file.assert_called_once_with( + path="CHANGELOG.md", + message="Update CHANGELOG.md", + content="new content", + sha="file-sha", + branch="feature-branch", + ) diff --git a/tests/unittest/test_github_provider_incremental.py b/tests/unittest/test_github_provider_incremental.py new file mode 100644 index 0000000000..2302a25a0c --- /dev/null +++ b/tests/unittest/test_github_provider_incremental.py @@ -0,0 +1,73 @@ +from datetime import datetime +from types import SimpleNamespace + +from pr_agent.git_providers.git_provider import IncrementalPR +from pr_agent.git_providers.github_provider import GithubProvider + +REVIEW_TIME = datetime(2026, 1, 3, 10, 0, 0) + + +def _commit(sha, author_date, committer_date=None): + committer = SimpleNamespace(date=committer_date) if committer_date else None + return SimpleNamespace( + sha=sha, + commit=SimpleNamespace(author=SimpleNamespace(date=author_date), committer=committer), + ) + + +def _make_provider(pr_commits): + provider = GithubProvider.__new__(GithubProvider) + provider.pr_commits = pr_commits + provider.previous_review = SimpleNamespace(created_at=REVIEW_TIME) + provider.incremental = IncrementalPR(True) + return provider + + +def test_rebased_commit_is_detected_as_new(): + # A force-push rewrites the commit after the review, but git preserves the author date. + commit = _commit("rebased", author_date=datetime(2026, 1, 2), committer_date=datetime(2026, 1, 5)) + provider = _make_provider([commit]) + + assert provider.get_commit_range() == [commit] + assert provider.incremental.first_new_commit is commit + + +def test_commit_older_than_review_is_not_new(): + commit = _commit("old", author_date=datetime(2026, 1, 1), committer_date=datetime(2026, 1, 1)) + provider = _make_provider([commit]) + + assert provider.get_commit_range() == [] + assert provider.incremental.last_seen_commit is commit + + +def test_falls_back_to_author_date_when_committer_is_missing(): + commit = _commit("no-committer", author_date=datetime(2026, 1, 5)) + provider = _make_provider([commit]) + + assert provider.get_commit_range() == [commit] + + +def test_fully_rebased_branch_falls_back_to_full_review(): + # Every commit post-dates the review, so there is no baseline commit to diff against. + commits = [ + _commit("a", author_date=datetime(2026, 1, 1), committer_date=datetime(2026, 1, 5)), + _commit("b", author_date=datetime(2026, 1, 2), committer_date=datetime(2026, 1, 5)), + ] + provider = _make_provider(commits) + provider.pr = SimpleNamespace(get_commits=lambda: commits) + provider.unreviewed_files_map = {} + provider.get_previous_review = lambda **kwargs: provider.previous_review + + provider._get_incremental_commits() + + assert provider.incremental.is_incremental is False + assert provider.incremental.last_seen_commit_sha is None + + +def test_only_commits_after_the_review_are_returned(): + reviewed = _commit("reviewed", author_date=datetime(2026, 1, 1), committer_date=datetime(2026, 1, 1)) + pushed = _commit("pushed", author_date=datetime(2026, 1, 4), committer_date=datetime(2026, 1, 4)) + provider = _make_provider([reviewed, pushed]) + + assert provider.get_commit_range() == [pushed] + assert provider.incremental.last_seen_commit is reviewed diff --git a/tests/unittest/test_github_provider_labels.py b/tests/unittest/test_github_provider_labels.py new file mode 100644 index 0000000000..15b4402d9d --- /dev/null +++ b/tests/unittest/test_github_provider_labels.py @@ -0,0 +1,65 @@ +""" +Tests for GitHub provider label publishing payload shape. + +``publish_labels`` PUTs label names to the issue \"Set labels for an issue\" endpoint, +which requires an array of label name strings. These tests pin that payload shape so +an accidental regression back to label objects cannot slip through. +""" + +from types import SimpleNamespace + +from pr_agent.git_providers.github_provider import GithubProvider + + +class _FakeRequester: + """Records REST calls instead of reaching the GitHub API.""" + + def __init__(self): + self.calls = [] + + def requestJsonAndCheck(self, method, url, input=None): + self.calls.append((method, url, input)) + return ({}, [{"name": "Review effort 3/5"}]) + + +def _make_provider(requester): + provider = GithubProvider.__new__(GithubProvider) + provider.pr = SimpleNamespace( + _requester=requester, + issue_url="https://api.github.com/repos/owner/repo/issues/42", + ) + return provider + + +def test_publish_labels_sends_label_names_as_strings(): + """Send an array of label names, not {\"name\", \"color\"} objects. + + The GitHub set-labels endpoint rejects an object payload with a 422 that the + provider downgrades to a warning, which would leave the labels silently unset. + """ + requester = _FakeRequester() + provider = _make_provider(requester) + + provider.publish_labels(["Review effort 3/5", "Enhancement"]) + + assert requester.calls == [ + ( + "PUT", + "https://api.github.com/repos/owner/repo/issues/42/labels", + ["Review effort 3/5", "Enhancement"], + ) + ] + + +def test_publish_labels_keeps_model_label_names_untouched(): + """The exact names produced by the review and generate_labels flows are sent.""" + requester = _FakeRequester() + provider = _make_provider(requester) + + provider.publish_labels(["Bug fix", "Bug fix with tests", "Possible security concern"]) + + assert requester.calls[0][2] == [ + "Bug fix", + "Bug fix with tests", + "Possible security concern", + ] diff --git a/tests/unittest/test_github_provider_repo_settings.py b/tests/unittest/test_github_provider_repo_settings.py index 97ec87ee97..52da106a96 100644 --- a/tests/unittest/test_github_provider_repo_settings.py +++ b/tests/unittest/test_github_provider_repo_settings.py @@ -1,6 +1,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch +import pytest from github import GithubException from pr_agent.git_providers.github_provider import GithubProvider @@ -12,12 +13,20 @@ def _provider_with_repo(repo_obj): return provider +@pytest.fixture(autouse=True) +def no_global_settings(): + # Patch get_settings in git_provider, where get_config_branch() lives, and keep the + # namespace-wide lookup out of the way so that patch does not leak into it. + with patch.object(GithubProvider, "_get_global_repo_settings", return_value=""): + yield + + def test_get_repo_settings_uses_config_branch_from_settings(): repo_obj = MagicMock() repo_obj.get_contents.return_value = SimpleNamespace(decoded_content=b"[config]\nmodel='x'") provider = _provider_with_repo(repo_obj) - with patch("pr_agent.git_providers.github_provider.get_settings") as mock_settings: + with patch("pr_agent.git_providers.git_provider.get_settings") as mock_settings: mock_settings.return_value.get.return_value = "feature-config" settings = provider.get_repo_settings() @@ -33,7 +42,7 @@ def test_get_repo_settings_falls_back_to_default_branch_on_missing_file_in_confi ] provider = _provider_with_repo(repo_obj) - with patch("pr_agent.git_providers.github_provider.get_settings") as mock_settings: + with patch("pr_agent.git_providers.git_provider.get_settings") as mock_settings: mock_settings.return_value.get.return_value = "feature-config" settings = provider.get_repo_settings() @@ -47,7 +56,7 @@ def test_get_repo_settings_uses_env_var_when_settings_are_missing(): repo_obj.get_contents.return_value = SimpleNamespace(decoded_content=b"[config]\nmodel='env'") provider = _provider_with_repo(repo_obj) - with patch("pr_agent.git_providers.github_provider.get_settings") as mock_settings, patch.dict( + with patch("pr_agent.git_providers.git_provider.get_settings") as mock_settings, patch.dict( "os.environ", {"PR_AGENT_CONFIG_BRANCH": "env-branch"}, clear=False, @@ -65,7 +74,7 @@ def test_get_repo_settings_whitespace_settings_falls_back_to_env_var(): repo_obj.get_contents.return_value = SimpleNamespace(decoded_content=b"[config]\nmodel='env'") provider = _provider_with_repo(repo_obj) - with patch("pr_agent.git_providers.github_provider.get_settings") as mock_settings, patch.dict( + with patch("pr_agent.git_providers.git_provider.get_settings") as mock_settings, patch.dict( "os.environ", {"PR_AGENT_CONFIG_BRANCH": "env-branch"}, clear=False, diff --git a/tests/unittest/test_github_provider_urls_and_diffs.py b/tests/unittest/test_github_provider_urls_and_diffs.py index 8712c6a68e..04d06e7150 100644 --- a/tests/unittest/test_github_provider_urls_and_diffs.py +++ b/tests/unittest/test_github_provider_urls_and_diffs.py @@ -10,9 +10,11 @@ from unittest.mock import Mock, patch import pytest +from github import GithubException, RateLimitExceededException from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo -from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.git_providers import github_provider +from pr_agent.git_providers.github_provider import GithubProvider, IncompletePullRequestFilesError def _bare_provider(): @@ -225,6 +227,7 @@ def _make_provider_for_diff(files): base=SimpleNamespace(sha="base-sha"), head=SimpleNamespace(sha="head-sha"), get_files=lambda: files, + changed_files=len(files), ) # repo_obj.compare returns an object with a merge_base_commit. p.repo_obj = SimpleNamespace( @@ -308,6 +311,62 @@ def test_additions_deletions_propagated(self, patched_helpers): assert diffs[0].num_minus_lines == 2 +class TestGetDiffFilesContentReads: + def test_added_file_skips_impossible_base_read(self, patched_helpers): + f = _make_file("added.py", "added") + p = _make_provider_for_diff([f]) + spy = Mock(return_value="content") + p._get_pr_file_content = spy + + p.get_diff_files() + + assert [call.args[1] for call in spy.call_args_list] == ["head-sha"] + + def test_removed_file_skips_impossible_head_read(self, patched_helpers): + f = _make_file("removed.py", "removed", patch="@@ -1 +0,0 @@\n-old") + p = _make_provider_for_diff([f]) + spy = Mock(return_value="content") + p._get_pr_file_content = spy + + p.get_diff_files() + + assert [call.args[1] for call in spy.call_args_list] == ["base-sha"] + + def test_modified_file_still_reads_both_revisions(self, patched_helpers): + f = _make_file("modified.py", "modified") + p = _make_provider_for_diff([f]) + spy = Mock(return_value="content") + p._get_pr_file_content = spy + + p.get_diff_files() + + assert [call.args[1] for call in spy.call_args_list] == ["head-sha", "base-sha"] + + def test_incremental_added_file_still_reads_both_revisions(self, patched_helpers): + f = _make_file("added.py", "added") + p = _make_provider_for_diff([f]) + p.incremental = SimpleNamespace(is_incremental=True, last_seen_commit_sha="prev-sha") + p.unreviewed_files_map = {"added.py": f} + spy = Mock(return_value="content") + p._get_pr_file_content = spy + + p.get_diff_files() + + assert [call.args[1] for call in spy.call_args_list] == ["head-sha", "prev-sha"] + + def test_incremental_empty_scope_uses_pr_level_removed_status(self, patched_helpers): + f = _make_file("removed.py", "removed", patch="@@ -1 +0,0 @@\n-old") + p = _make_provider_for_diff([f]) + p.incremental = SimpleNamespace(is_incremental=True, last_seen_commit_sha="prev-sha") + p.unreviewed_files_map = {} + spy = Mock(return_value="content") + p._get_pr_file_content = spy + + p.get_diff_files() + + assert [call.args[1] for call in spy.call_args_list] == ["base-sha"] + + class TestGetDiffFilesRename: """A pure GitHub rename carries no `.patch` and reports 0 additions/0 deletions, so `previous_filename` is the only place the old path lives.""" @@ -371,3 +430,405 @@ def test_incremental_content_looked_up_at_old_path(self, patched_helpers): original_content_call = spy.call_args_list[-1] assert original_content_call.args[1] == "prev-sha" assert original_content_call.kwargs.get("path") == "old_dir/module.py" + + +# --------------------------------------------------------------------------- +# Complete pull-request file collection +# --------------------------------------------------------------------------- +class _RequestContext(dict): + def __init__(self, values=None, *, exists=True): + super().__init__(values or {}) + self._exists = exists + + def exists(self): + return self._exists + + def __eq__(self, other): + if not isinstance(other, _RequestContext): + return NotImplemented + return super().__eq__(other) and self._exists == other._exists + + def __ne__(self, other): + equal = self.__eq__(other) + if equal is NotImplemented: + return NotImplemented + return not equal + + +class _FakePullRequest: + def __init__(self, files, changed_files): + self.files = files + self.changed_files = changed_files + self.get_files_calls = 0 + + def get_files(self): + self.get_files_calls += 1 + if isinstance(self.files, BaseException): + raise self.files + return self.files + + +class _ChangedFilesErrorPullRequest(_FakePullRequest): + def __init__(self, files, error): + super().__init__(files, 0) + self.error = error + self.changed_files_calls = 0 + + @property + def changed_files(self): + self.changed_files_calls += 1 + raise self.error + + @changed_files.setter + def changed_files(self, value): + pass + + +class _SequencedFilesPullRequest(_FakePullRequest): + def __init__(self, outcomes, changed_files): + super().__init__(None, changed_files) + self.outcomes = list(outcomes) + + def get_files(self): + self.get_files_calls += 1 + outcome = self.outcomes.pop(0) + if isinstance(outcome, BaseException): + raise outcome + return outcome + + +class _SequencedChangedFilesPullRequest: + def __init__(self, files, outcomes): + self.files = files + self.outcomes = list(outcomes) + self.get_files_calls = 0 + self.changed_files_calls = 0 + + def get_files(self): + self.get_files_calls += 1 + return self.files + + @property + def changed_files(self): + self.changed_files_calls += 1 + outcome = self.outcomes.pop(0) + if isinstance(outcome, BaseException): + raise outcome + return outcome + + +class _ExplodingIterable: + def __init__(self, error): + self.error = error + self.iteration_calls = 0 + + def __iter__(self): + self.iteration_calls += 1 + raise self.error + + +class _TransientIterable: + def __init__(self, error, files): + self.error = error + self.files = files + self.iteration_calls = 0 + + def __iter__(self): + self.iteration_calls += 1 + if self.iteration_calls == 1: + raise self.error + return iter(self.files) + + +def _make_provider_for_file_collection(pr, *, incremental=False, unreviewed_files_map=None): + provider = _bare_provider() + provider.pr = pr + provider.git_files = None + provider.diff_files = None + provider.incremental = SimpleNamespace(is_incremental=incremental) + provider.unreviewed_files_map = unreviewed_files_map or {} + return provider + + +def _set_request_context(monkeypatch, values=None): + request_context = _RequestContext(values) + monkeypatch.setattr(github_provider, "context", request_context) + return request_context + + +def test_request_context_equality_includes_existence_state(): + present = _RequestContext({"git_files": ["first"]}) + same = _RequestContext({"git_files": ["first"]}) + absent = _RequestContext({"git_files": ["first"]}, exists=False) + + assert present == same + assert present != absent + assert present.__eq__({"git_files": ["first"]}) is NotImplemented + assert present.__ne__({"git_files": ["first"]}) is NotImplemented + + +class TestCompletePullRequestFiles: + @pytest.mark.parametrize( + ("files", "changed_files"), + [(["first"], 2), (["first", "second"], 1)], + ids=["fewer-files-than-reported", "more-files-than-reported"], + ) + def test_mismatched_count_fails_closed_without_caching(self, monkeypatch, files, changed_files): + request_context = _set_request_context(monkeypatch) + pr = _FakePullRequest(files, changed_files) + provider = _make_provider_for_file_collection(pr) + + with pytest.raises(IncompletePullRequestFilesError): + provider.get_files() + + assert provider.git_files is None + assert "git_files" not in request_context + assert pr.get_files_calls == 1 + + @pytest.mark.parametrize("changed_files", [None, "two", True]) + def test_invalid_changed_files_metadata_fails_closed_without_caching(self, monkeypatch, changed_files): + request_context = _set_request_context(monkeypatch) + provider = _make_provider_for_file_collection(_FakePullRequest(["first"], changed_files)) + + with pytest.raises(IncompletePullRequestFilesError): + provider.get_files() + + assert provider.git_files is None + assert "git_files" not in request_context + + def test_changed_files_access_error_propagates_without_caching(self, monkeypatch): + request_context = _set_request_context(monkeypatch) + error = RuntimeError("changed_files failed") + pr = _ChangedFilesErrorPullRequest(["first"], error) + provider = _make_provider_for_file_collection(pr) + + with pytest.raises(RuntimeError, match="changed_files failed") as raised: + provider.get_files() + + assert raised.value is error + assert pr.get_files_calls == 2 + assert pr.changed_files_calls == 2 + assert provider.git_files is None + assert "git_files" not in request_context + + def test_rate_limit_request_propagates_immediately_without_caching(self, monkeypatch): + request_context = _set_request_context(monkeypatch) + error = RateLimitExceededException(403, {"message": "API rate limit exceeded"}, None) + pr = _FakePullRequest(error, 1) + provider = _make_provider_for_file_collection(pr) + + with pytest.raises(RateLimitExceededException) as raised: + provider.get_files() + + assert raised.value is error + assert pr.get_files_calls == 1 + assert provider.git_files is None + assert "git_files" not in request_context + + def test_rate_limit_metadata_error_propagates_immediately_without_caching(self, monkeypatch): + request_context = _set_request_context(monkeypatch) + error = RateLimitExceededException(403, {"message": "API rate limit exceeded"}, None) + pr = _ChangedFilesErrorPullRequest(["first"], error) + provider = _make_provider_for_file_collection(pr) + + with pytest.raises(RateLimitExceededException) as raised: + provider.get_files() + + assert raised.value is error + assert pr.get_files_calls == 1 + assert pr.changed_files_calls == 1 + assert provider.git_files is None + assert "git_files" not in request_context + + def test_non_rate_limit_403_uses_the_ordinary_retry_policy(self, monkeypatch): + request_context = _set_request_context(monkeypatch) + error = GithubException(403, {"message": "Resource not accessible by integration"}, None) + pr = _FakePullRequest(error, 1) + provider = _make_provider_for_file_collection(pr) + + with pytest.raises(GithubException) as raised: + provider.get_files() + + assert raised.value is error + assert pr.get_files_calls == 2 + assert provider.git_files is None + assert "git_files" not in request_context + + @pytest.mark.parametrize( + "failure", + [RuntimeError("request failed"), _ExplodingIterable(RuntimeError("page failed"))], + ) + def test_file_collection_errors_propagate_after_retry_without_caching(self, monkeypatch, failure): + request_context = _set_request_context(monkeypatch) + pr = _FakePullRequest(failure, 1) + provider = _make_provider_for_file_collection(pr) + + with pytest.raises(RuntimeError) as raised: + provider.get_files() + + expected_error = failure.error if isinstance(failure, _ExplodingIterable) else failure + assert raised.value is expected_error + assert pr.get_files_calls == 2 + if isinstance(failure, _ExplodingIterable): + assert failure.iteration_calls == 2 + assert provider.git_files is None + assert "git_files" not in request_context + + def test_transient_file_request_recovers_and_populates_caches(self, monkeypatch): + request_context = _set_request_context(monkeypatch) + files = ["first"] + pr = _SequencedFilesPullRequest([RuntimeError("request failed"), files], len(files)) + provider = _make_provider_for_file_collection(pr) + + assert provider.get_files() == files + assert pr.get_files_calls == 2 + assert provider.git_files == files + assert request_context["git_files"] == files + + def test_transient_materialization_error_recovers_and_populates_caches(self, monkeypatch): + request_context = _set_request_context(monkeypatch) + files = ["first"] + iterable = _TransientIterable(RuntimeError("page failed"), files) + pr = _FakePullRequest(iterable, len(files)) + provider = _make_provider_for_file_collection(pr) + + assert provider.get_files() == files + assert pr.get_files_calls == 2 + assert iterable.iteration_calls == 2 + assert provider.git_files == files + assert request_context["git_files"] == files + + def test_transient_changed_files_error_recovers_and_populates_caches(self, monkeypatch): + request_context = _set_request_context(monkeypatch) + files = ["first"] + pr = _SequencedChangedFilesPullRequest(files, [RuntimeError("metadata failed"), len(files)]) + provider = _make_provider_for_file_collection(pr) + + assert provider.get_files() == files + assert pr.get_files_calls == 2 + assert pr.changed_files_calls == 2 + assert provider.git_files == files + assert request_context["git_files"] == files + + def test_transient_failure_then_mismatch_fails_closed_without_caching(self, monkeypatch): + request_context = _set_request_context(monkeypatch) + pr = _SequencedFilesPullRequest([RuntimeError("request failed"), ["first"]], 2) + provider = _make_provider_for_file_collection(pr) + + with pytest.raises(IncompletePullRequestFilesError): + provider.get_files() + + assert pr.get_files_calls == 2 + assert provider.git_files is None + assert "git_files" not in request_context + + def test_get_diff_files_recovers_within_collection_retry(self, monkeypatch, patched_helpers): + _set_request_context(monkeypatch) + file = _make_file("first.py", "modified") + provider = _make_provider_for_diff([file]) + provider.pr.get_files = Mock(side_effect=[RuntimeError("request failed"), [file]]) + provider._get_pr_file_content = lambda file, sha, path=None: "content" + + diffs = provider.get_diff_files() + + assert [diff.filename for diff in diffs] == ["first.py"] + assert provider.pr.get_files.call_count == 2 + + def test_get_diff_files_preserves_completeness_error_without_retrying(self, monkeypatch): + _set_request_context(monkeypatch) + pr = _FakePullRequest(["first"], 2) + provider = _make_provider_for_file_collection(pr) + settings = Mock() + settings.get.return_value = 5 + monkeypatch.setattr(github_provider, "get_settings", lambda: settings) + + with pytest.raises(IncompletePullRequestFilesError): + provider.get_diff_files() + + assert pr.get_files_calls == 1 + + def test_matching_count_populates_request_and_instance_caches(self, monkeypatch): + request_context = _set_request_context(monkeypatch) + files = ["first", "second"] + pr = _FakePullRequest(files, len(files)) + provider = _make_provider_for_file_collection(pr) + + assert provider.get_files() == files + assert provider.git_files == files + assert request_context["git_files"] == files + assert pr.get_files_calls == 1 + + def test_request_context_cache_avoids_another_api_call(self, monkeypatch): + files = ["first"] + _set_request_context(monkeypatch, {"git_files": files}) + pr = _FakePullRequest(RuntimeError("should not fetch"), 1) + provider = _make_provider_for_file_collection(pr) + + assert provider.get_files() == files + assert pr.get_files_calls == 0 + + def test_none_request_context_cache_is_refetched(self, monkeypatch): + request_context = _set_request_context(monkeypatch, {"git_files": None}) + files = ["first"] + pr = _FakePullRequest(files, len(files)) + provider = _make_provider_for_file_collection(pr) + + assert provider.get_files() == files + assert request_context["git_files"] == files + assert pr.get_files_calls == 1 + + def test_instance_cache_avoids_another_api_call(self, monkeypatch): + _set_request_context(monkeypatch) + files = ["first"] + pr = _FakePullRequest(RuntimeError("should not fetch"), 1) + provider = _make_provider_for_file_collection(pr) + provider.git_files = files + + assert provider.get_files() == files + assert pr.get_files_calls == 0 + + @pytest.mark.parametrize("cache_owner", ["request", "instance"]) + def test_empty_cache_is_reused_without_fetching(self, monkeypatch, cache_owner): + request_context = _set_request_context(monkeypatch, {"git_files": []} if cache_owner == "request" else None) + pr = _FakePullRequest(RuntimeError("should not fetch"), 0) + provider = _make_provider_for_file_collection(pr) + if cache_owner == "instance": + provider.git_files = [] + + assert provider.get_files() == [] + assert pr.get_files_calls == 0 + if cache_owner == "request": + assert request_context["git_files"] == [] + + def test_get_pr_file_paths_uses_complete_collector_during_incremental_review(self, monkeypatch): + request_context = _set_request_context(monkeypatch) + files = ["full-file"] + pr = _FakePullRequest(files, len(files)) + provider = _make_provider_for_file_collection( + pr, + incremental=True, + unreviewed_files_map={"incremental-file": "incremental-file"}, + ) + + assert provider.get_pr_file_paths() == files + assert provider.git_files == files + assert request_context["git_files"] == files + assert pr.get_files_calls == 1 + + def test_incremental_subset_bypasses_full_collection_and_count_access(self, monkeypatch): + _set_request_context(monkeypatch) + error = RuntimeError("changed_files should not be read") + pr = _ChangedFilesErrorPullRequest([], error) + file = "incremental-file" + provider = _make_provider_for_file_collection( + pr, + incremental=True, + unreviewed_files_map={file: file}, + ) + + files = provider.get_files() + + assert files == [file] + assert isinstance(files, list) + assert pr.get_files_calls == 0 + assert pr.changed_files_calls == 0 diff --git a/tests/unittest/test_github_webhook_delivery.py b/tests/unittest/test_github_webhook_delivery.py new file mode 100644 index 0000000000..f6abf0c279 --- /dev/null +++ b/tests/unittest/test_github_webhook_delivery.py @@ -0,0 +1,232 @@ +"""Tests for opt-in GitHub webhook delivery deduplication.""" + +import asyncio +import copy +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from starlette.background import BackgroundTasks +from starlette_context import request_cycle_context + +from pr_agent.config_loader import global_settings +from pr_agent.servers import github_app +from pr_agent.servers import utils as servers_utils + + +@pytest.fixture +def delivery_clock(monkeypatch): + now = [0.0] + monkeypatch.setattr( + servers_utils.DefaultDictWithTimeout, + "_DefaultDictWithTimeout__time", + staticmethod(lambda: now[0]), + ) + monkeypatch.setattr(github_app, "time", SimpleNamespace(monotonic=lambda: now[0])) + return now + + +@pytest.fixture +def delivery_context(monkeypatch, delivery_clock): + settings = copy.deepcopy(global_settings) + settings.set("GITHUB_APP.WEBHOOK_DELIVERY_DEDUPLICATION", True) + settings.set("GITHUB_APP.PUSH_TRIGGER_PENDING_TASKS_TTL", 300) + monkeypatch.setattr(github_app, "_WEBHOOK_DELIVERY_TTL", 300) + monkeypatch.setattr(servers_utils, "_push_trigger_states_by_ttl", {}) + monkeypatch.setattr(servers_utils, "_active_push_trigger_states", {}) + monkeypatch.setattr( + github_app, + "_completed_webhook_deliveries", + servers_utils.DefaultDictWithTimeout( + float, ttl=300, update_key_time_on_get=False + ), + ) + with request_cycle_context({"settings": settings}): + yield settings + + +@pytest.mark.asyncio +@pytest.mark.parametrize("delivery_id", ["delivery-1", None]) +async def test_github_webhook_route_forwards_delivery_id(monkeypatch, delivery_id): + body = {"installation": {"id": 1}, "action": "created"} + observed = [] + + class Request: + headers = {"X-GitHub-Event": "issue_comment"} + if delivery_id is not None: + headers["X-GitHub-Delivery"] = delivery_id + + async def fake_get_body(_request): + return body + + async def fake_handle_request(*args, **kwargs): + observed.append((args, kwargs)) + + monkeypatch.setattr(github_app, "get_body", fake_get_body) + monkeypatch.setattr(github_app, "handle_request", fake_handle_request) + background_tasks = BackgroundTasks() + + with request_cycle_context({}): + await github_app.handle_github_webhooks(background_tasks, Request(), object()) + await background_tasks() + + assert observed == [ + ((body,), {"event": "issue_comment", "delivery_id": delivery_id}), + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("enabled", "delivery_id"), [(False, "delivery-1"), (True, None), (True, "")]) +async def test_handle_request_bypasses_deduplication_when_disabled_or_missing_id( + monkeypatch, delivery_context, enabled, delivery_id +): + delivery_context.set("GITHUB_APP.WEBHOOK_DELIVERY_DEDUPLICATION", enabled) + dispatch = AsyncMock(return_value={}) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch) + body = {"action": "created"} + + assert await github_app.handle_request(body, "issue_comment", delivery_id=delivery_id) == {} + assert await github_app.handle_request(body, "issue_comment", delivery_id=delivery_id) == {} + + assert dispatch.await_count == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("result", [True, {}, None]) +async def test_handle_request_suppresses_completed_or_intentionally_ignored_delivery( + monkeypatch, delivery_context, result +): + dispatch = AsyncMock(return_value=result) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch) + body = {"action": "created"} + + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + + dispatch.assert_awaited_once_with(body, "issue_comment", "created") + + +@pytest.mark.asyncio +async def test_handle_request_keeps_distinct_delivery_ids_independent(monkeypatch, delivery_context): + dispatch = AsyncMock(return_value={}) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch) + body = {"action": "created"} + + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-2") + + assert dispatch.await_count == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("elapsed", [0, 301]) +async def test_handle_request_suppresses_active_delivery_even_after_ttl( + monkeypatch, delivery_context, delivery_clock, elapsed +): + entered = asyncio.Event() + release = asyncio.Event() + + async def dispatch(_body, _event, _action): + entered.set() + await release.wait() + return {} + + dispatch_mock = AsyncMock(side_effect=dispatch) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch_mock) + body = {"action": "created"} + first = asyncio.create_task(github_app.handle_request(body, "issue_comment", delivery_id="delivery-1")) + try: + await asyncio.wait_for(entered.wait(), 1) + delivery_clock[0] += elapsed + await asyncio.wait_for( + github_app.handle_request(body, "issue_comment", delivery_id="delivery-1"), 1 + ) + dispatch_mock.assert_awaited_once() + finally: + release.set() + await asyncio.wait_for(first, 1) + + assert not servers_utils._active_push_trigger_states + + +@pytest.mark.asyncio +async def test_active_delivery_does_not_block_a_different_delivery(monkeypatch, delivery_context): + entered = asyncio.Event() + release = asyncio.Event() + + async def dispatch(body, _event, _action): + if body["comment_id"] == 1: + entered.set() + await release.wait() + return {} + + dispatch_mock = AsyncMock(side_effect=dispatch) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch_mock) + first = asyncio.create_task( + github_app.handle_request({"action": "created", "comment_id": 1}, "issue_comment", delivery_id="delivery-1") + ) + try: + await asyncio.wait_for(entered.wait(), 1) + await asyncio.wait_for( + github_app.handle_request( + {"action": "created", "comment_id": 2}, "issue_comment", delivery_id="delivery-2" + ), + 1, + ) + assert dispatch_mock.await_count == 2 + finally: + release.set() + await asyncio.wait_for(first, 1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("error", [RuntimeError("agent failed"), asyncio.CancelledError()]) +async def test_failed_or_cancelled_dispatch_can_retry(monkeypatch, delivery_context, error): + dispatch = AsyncMock(side_effect=[error, {}]) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch) + body = {"action": "created"} + + with pytest.raises(type(error)): + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + assert not servers_utils._active_push_trigger_states + + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + + assert dispatch.await_count == 2 + + +@pytest.mark.asyncio +async def test_duplicate_does_not_extend_completed_delivery_ttl(monkeypatch, delivery_context, delivery_clock): + dispatch = AsyncMock(return_value={}) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch) + body = {"action": "created"} + + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + delivery_clock[0] = 299 + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + assert dispatch.await_count == 1 + + delivery_clock[0] = 301 + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + assert dispatch.await_count == 2 + + +@pytest.mark.asyncio +async def test_completed_delivery_ttl_starts_after_dispatch(monkeypatch, delivery_context, delivery_clock): + async def dispatch(_body, _event, _action): + delivery_clock[0] += 301 + return {} + + dispatch_mock = AsyncMock(side_effect=dispatch) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch_mock) + body = {"action": "created"} + + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + delivery_clock[0] = 600 + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + assert dispatch_mock.await_count == 1 + + delivery_clock[0] = 602 + await github_app.handle_request(body, "issue_comment", delivery_id="delivery-1") + assert dispatch_mock.await_count == 2 diff --git a/tests/unittest/test_github_webhook_delivery_failures.py b/tests/unittest/test_github_webhook_delivery_failures.py new file mode 100644 index 0000000000..6df03c21ca --- /dev/null +++ b/tests/unittest/test_github_webhook_delivery_failures.py @@ -0,0 +1,142 @@ +"""Exercise failed delivery retries through the real GitHub event handlers.""" + +import asyncio +import copy +from unittest.mock import AsyncMock, Mock, call + +import pytest +from starlette_context import request_cycle_context + +from pr_agent.config_loader import get_settings +from pr_agent.identity_providers.identity_provider import Eligibility +from pr_agent.servers import github_app +from pr_agent.servers import utils as servers_utils + +EVENTS = [ + ("issue_comment", "created"), + ("pull_request", "opened"), + ("pull_request_review", "submitted"), + ("pull_request", "synchronize"), +] +AUTO_EVENTS = EVENTS[1:] +PR_URL = "https://api.github.com/repos/org/repo/pulls/1" + + +@pytest.fixture +def delivery_agent(monkeypatch): + settings = copy.deepcopy(get_settings()) + settings.set("GITHUB_APP.WEBHOOK_DELIVERY_DEDUPLICATION", True) + settings.set("GITHUB_APP.HANDLE_PR_ACTIONS", ["opened"]) + settings.set("GITHUB_APP.HANDLE_PUSH_TRIGGER", True) + settings.set("GITHUB_APP.PUSH_TRIGGER_IGNORE_MERGE_COMMITS", False) + settings.set("GITHUB_APP.FEEDBACK_ON_DRAFT_PR", False) + settings.set("GITHUB_APP.REVIEW_AUTHOR_TYPES", ["User"]) + settings.set("GITHUB_APP.REVIEW_STATES", ["changes_requested"]) + settings.set("GITHUB_APP.PR_COMMANDS", ["/review"]) + settings.set("GITHUB_APP.REVIEW_COMMANDS", ["/review"]) + settings.set("GITHUB_APP.PUSH_COMMANDS", ["/review"]) + settings.set("CONFIG.DISABLE_AUTO_FEEDBACK", False) + agent = Mock(handle_request=AsyncMock(return_value=True)) + identity = Mock() + identity.verify_eligibility.return_value = Eligibility.ELIGIBLE + monkeypatch.setattr(github_app, "PRAgent", lambda: agent) + monkeypatch.setattr(github_app, "get_identity_provider", lambda: identity) + monkeypatch.setattr(github_app, "get_git_provider_with_context", lambda **kwargs: Mock()) + monkeypatch.setattr(github_app, "apply_repo_settings", lambda api_url: None) + monkeypatch.setattr(servers_utils, "_push_trigger_states_by_ttl", {}) + monkeypatch.setattr(servers_utils, "_active_push_trigger_states", {}) + monkeypatch.setattr( + github_app, + "_completed_webhook_deliveries", + servers_utils.DefaultDictWithTimeout( + float, ttl=github_app._WEBHOOK_DELIVERY_TTL, update_key_time_on_get=False + ), + ) + with request_cycle_context({"settings": settings}): + yield agent, "delivery-1" + + +def _body(action): + return { + "action": action, + "installation": {"id": 1}, + "sender": {"id": 2, "login": "user", "type": "User"}, + "repository": {"full_name": "org/repo"}, + "pull_request": { + "url": PR_URL, + "state": "open", + "draft": False, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + }, + "comment": {"body": "/review", "pull_request_url": PR_URL, "id": 42}, + "review": {"state": "changes_requested", "user": {"type": "User"}}, + "before": "before-sha", + "after": "after-sha", + } + + +@pytest.mark.parametrize(("event", "action"), EVENTS) +async def test_false_agent_result_can_be_retried(delivery_agent, event, action): + agent, delivery_id = delivery_agent + agent.handle_request.side_effect = [False, True] + body = _body(action) + + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + + assert agent.handle_request.await_count == 2 + + +@pytest.mark.parametrize(("event", "action"), AUTO_EVENTS) +@pytest.mark.parametrize("failure", [False, RuntimeError("agent failed")], ids=["false", "exception"]) +async def test_auto_commands_continue_after_failure_and_allow_retry(delivery_agent, event, action, failure): + agent, delivery_id = delivery_agent + for commands in ("PR_COMMANDS", "REVIEW_COMMANDS", "PUSH_COMMANDS"): + get_settings().set(f"GITHUB_APP.{commands}", ["/review", "/describe"]) + agent.handle_request.side_effect = [failure, True, True, True] + body = _body(action) + + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + + assert agent.handle_request.await_args_list == [ + call(PR_URL, ["/review"]), + call(PR_URL, ["/describe"]), + call(PR_URL, ["/review"]), + call(PR_URL, ["/describe"]), + ] + + +@pytest.mark.parametrize(("event", "action"), EVENTS) +async def test_cancelled_agent_call_propagates_and_allows_retry(delivery_agent, event, action): + agent, delivery_id = delivery_agent + agent.handle_request.side_effect = [asyncio.CancelledError(), True] + body = _body(action) + + with pytest.raises(asyncio.CancelledError): + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + + assert agent.handle_request.await_count == 2 + + +@pytest.mark.parametrize(("event", "action"), EVENTS) +async def test_intentionally_ignored_delivery_is_completed(delivery_agent, monkeypatch, event, action): + agent, delivery_id = delivery_agent + body = _body(action) + if event == "issue_comment": + body["comment"]["body"] = "Thanks for the review" + else: + body["pull_request"]["draft"] = True + dispatch = AsyncMock(wraps=github_app._dispatch_request) + monkeypatch.setattr(github_app, "_dispatch_request", dispatch) + + await github_app.handle_request(body, event, delivery_id) + await github_app.handle_request(body, event, delivery_id) + + dispatch.assert_awaited_once() + agent.handle_request.assert_not_awaited() diff --git a/tests/unittest/test_gitlab_batch_publish_suggestions.py b/tests/unittest/test_gitlab_batch_publish_suggestions.py index abcbe83010..239701f30f 100644 --- a/tests/unittest/test_gitlab_batch_publish_suggestions.py +++ b/tests/unittest/test_gitlab_batch_publish_suggestions.py @@ -1,5 +1,10 @@ +import re from unittest.mock import MagicMock, patch +import pytest +from gitlab import GitlabCreateError +from requests.exceptions import RequestException + from pr_agent.algo import inline_comment_dedup as dedup from pr_agent.git_providers.gitlab_provider import GitLabProvider @@ -14,6 +19,7 @@ class _FakeTargetFile: filename = "a.py" old_filename = "a.py" head_file = "line1\nline2\nline3\n" + patch = "@@ -1,2 +1,3 @@\n line1\n line2\n+line3\n" def _suggestion(**overrides): @@ -38,6 +44,7 @@ def _gl_provider(): clears them - so tests exercise the same create -> list -> bulk_publish flow the real code depends on, instead of asserting on call counts alone.""" p = GitLabProvider.__new__(GitLabProvider) + p.RE_HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[ ]?(.*)") p.id_mr = 1 p.mr = MagicMock() p.mr.discussions.list.return_value = [] @@ -94,6 +101,40 @@ def test_flag_off_posts_live_discussions_and_skips_bulk_publish(): p.mr.draft_notes.bulk_publish.assert_not_called() +def test_context_line_suggestion_sends_both_gitlab_line_numbers(): + p = _gl_provider() + gs = _settings(as_review=False) + try: + assert p.publish_code_suggestions([_suggestion()]) is True + finally: + gs.stop() + + position = p.mr.discussions.create.call_args.args[0]['position'] + assert position['old_line'] == 2 + assert position['new_line'] == 2 + + +@pytest.mark.parametrize("start, expected", [ + (4, (3, 4)), # context line whose text also appears as the added line 2 + (3, (2, 3)), # blank context line +]) +def test_anchor_is_positional_not_first_text_match(start, expected): + class _RepeatingTargetFile(_FakeTargetFile): + head_file = "a\nb\n\nb\n" + patch = "@@ -1,3 +1,4 @@\n a\n+b\n \n b\n" + + p = _gl_provider() + p.get_diff_files = MagicMock(return_value=[_RepeatingTargetFile()]) + gs = _settings(as_review=False) + try: + assert p.publish_code_suggestions([_suggestion(relevant_lines_start=start, relevant_lines_end=start)]) is True + finally: + gs.stop() + + position = p.mr.discussions.create.call_args.args[0]['position'] + assert (position['old_line'], position['new_line']) == expected + + def test_flag_on_queues_draft_notes_and_bulk_publishes_once(): p = _gl_provider() gs = _settings(as_review=True) @@ -119,7 +160,7 @@ def test_flag_on_fallback_uses_draft_note_not_live_note(): def _create_first_call_rejected(payload): calls.append(payload) if len(calls) == 1: - raise RuntimeError("position rejected") + raise GitlabCreateError("position rejected") return original_create(payload) p.mr.draft_notes.create.side_effect = _create_first_call_rejected @@ -142,7 +183,7 @@ def test_draft_totally_unavailable_falls_back_to_a_live_comment_not_a_dropped_su # draft-notes endpoint is unsupported/erroring for this MR. The suggestion must still be # posted, just live instead of batched - not silently dropped. p = _gl_provider() - p.mr.draft_notes.create.side_effect = RuntimeError("draft notes unavailable") + p.mr.draft_notes.create.side_effect = GitlabCreateError("draft notes unavailable") gs = _settings(as_review=True) try: assert p.publish_code_suggestions([_suggestion()]) is True @@ -156,7 +197,7 @@ def test_draft_totally_unavailable_falls_back_to_a_live_comment_not_a_dropped_su def test_bulk_publish_failure_is_caught_and_does_not_propagate(): p = _gl_provider() - p.mr.draft_notes.bulk_publish.side_effect = RuntimeError("network error") + p.mr.draft_notes.bulk_publish.side_effect = RequestException("network error") gs = _settings(as_review=True) try: # must not raise, and must still report success for the individually-queued suggestions diff --git a/tests/unittest/test_gitlab_diff_version_fetch.py b/tests/unittest/test_gitlab_diff_version_fetch.py new file mode 100644 index 0000000000..4081eda1d8 --- /dev/null +++ b/tests/unittest/test_gitlab_diff_version_fetch.py @@ -0,0 +1,40 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from pr_agent.git_providers.gitlab_provider import GitLabProvider + + +def test_set_merge_request_fetches_only_latest_diff_version(): + provider = GitLabProvider.__new__(GitLabProvider) + provider._parse_merge_request_url = Mock(return_value=("group/repo", 42)) + + latest_diff = object() + list_diffs = Mock(return_value=[latest_diff]) + mr = SimpleNamespace(diffs=SimpleNamespace(list=list_diffs)) + provider._get_merge_request = Mock(return_value=mr) + + provider._set_merge_request("https://gitlab.example/group/repo/-/merge_requests/42") + + assert provider.id_project == "group/repo" + assert provider.id_mr == 42 + assert provider.mr is mr + assert provider.last_diff is latest_diff + list_diffs.assert_called_once_with(page=1, per_page=1, get_all=False) + + +def test_get_relevant_diff_fetches_only_latest_diff_version(): + provider = GitLabProvider.__new__(GitLabProvider) + + latest_diff = object() + list_diffs = Mock(return_value=[latest_diff]) + provider.mr = SimpleNamespace(diffs=SimpleNamespace(list=list_diffs)) + provider.last_diff = latest_diff + provider._get_merge_request_changes = Mock(return_value={ + "changes": [{"new_path": "src/app.py", "diff": "@@\n+hello"}], + "diff_refs": {"base_sha": "base", "head_sha": "head"}, + }) + provider._expand_submodule_changes = Mock(side_effect=lambda changes, diff_refs=None: changes) + + assert provider.get_relevant_diff("src/app.py", "hello") is latest_diff + + list_diffs.assert_called_once_with(page=1, per_page=1, get_all=False) diff --git a/tests/unittest/test_gitlab_diffs_api.py b/tests/unittest/test_gitlab_diffs_api.py new file mode 100644 index 0000000000..e571a3effd --- /dev/null +++ b/tests/unittest/test_gitlab_diffs_api.py @@ -0,0 +1,374 @@ +"""Exercise MR diff retrieval through python-gitlab's HTTP and pagination code.""" +import json +from types import SimpleNamespace +from unittest.mock import Mock, call +from urllib.parse import urlparse + +import gitlab +import pytest +import requests +from requests.adapters import BaseAdapter + +from pr_agent.config_loader import get_settings +from pr_agent.git_providers.git_provider import IncrementalPR +from pr_agent.git_providers.gitlab_provider import GitLabProvider, IncompleteGitLabDiffError + + +def _change(path, **kwargs): + return { + "old_path": path, "new_path": path, "diff": "@@ -1 +1 @@\n-old\n+new\n", + "new_file": False, "deleted_file": False, "renamed_file": False, + **kwargs, + } + + +class DiffTransport(BaseAdapter): + def __init__(self, responses, metadata, metadata_responses=None): + super().__init__() + self.responses = iter(responses) + self.metadata = metadata + self.metadata_responses = iter(metadata_responses) if metadata_responses is not None else None + self.requests = [] + + @property + def diff_requests(self): + return [request for request in self.requests if urlparse(request.url).path.endswith("/diffs")] + + def send(self, request, **kwargs): + self.requests.append(request) + assert request.method == "GET" + if urlparse(request.url).path.endswith("/diffs"): + status, payload, headers = next(self.responses) + else: + assert urlparse(request.url).path.endswith("/merge_requests/7") + status, payload, headers = ( + next(self.metadata_responses) if self.metadata_responses is not None else (200, self.metadata, {}) + ) + response = requests.Response() + response.status_code = status + response._content = json.dumps(payload).encode() + response.headers.update({"Content-Type": "application/json", **headers}) + response.request = request + return response + + def close(self): + pass + + +@pytest.fixture +def provider_factory(): + sessions = [] + + def make(responses, count="2", project_id="group/sub/repo", metadata_responses=None): + metadata = _metadata(count=count) + transport = DiffTransport(responses, metadata, metadata_responses) + session = requests.Session() + session.mount("https://", transport) + sessions.append(session) + provider = GitLabProvider.__new__(GitLabProvider) + provider.gl = gitlab.Gitlab("https://gitlab.example/gitlab", private_token="offline-token", session=session) + provider.id_project = project_id + provider.id_mr = 7 + project = provider.gl.projects.get(project_id, lazy=True) + provider.mr = project.mergerequests.get(7, lazy=True) + if count is not None: + provider.mr.changes_count = count + provider.mr.diff_refs = {"base_sha": "base", "start_sha": "start", "head_sha": "head"} + provider.git_files = None + provider.diff_files = None + provider.incremental = IncrementalPR(False) + provider._expand_submodule_changes = Mock(side_effect=lambda changes, diff_refs: changes) + provider.get_pr_file_content = Mock(side_effect=lambda path, ref: "old\n" if ref == "base" else "new\n") + return provider, transport + + yield make + for session in sessions: + session.close() + + +def _metadata(count="2", **refs): + return { + **({"changes_count": count} if count is not None else {}), + "sha": refs.get("head_sha", "head"), + "diff_refs": {"base_sha": "base", "start_sha": "start", "head_sha": "head", **refs}, + } + + +def _metadata_reads(*snapshots): + return [(200, snapshot, {}) for snapshot in snapshots] + + +def _pages(first, second): + next_url = "https://gitlab.example/gitlab/api/v4/projects/group%2Fsub%2Frepo/merge_requests/7/diffs?page=2" + return [(200, first, {"Link": f'<{next_url}>; rel="next"'}), (200, second, {})] + + +def test_collects_all_pages_using_the_configured_client(provider_factory): + renamed = _change("new.py", old_path="old.py", renamed_file=True) + provider, transport = provider_factory(_pages([_change("first.py")], [renamed])) + + files = provider.get_diff_files() + + assert [file.filename for file in files] == ["first.py", "new.py"] + assert files[1].old_filename == "old.py" + assert [urlparse(request.url).path for request in transport.diff_requests] == [ + "/gitlab/api/v4/projects/group%2Fsub%2Frepo/merge_requests/7/diffs", + "/gitlab/api/v4/projects/group%2Fsub%2Frepo/merge_requests/7/diffs", + ] + assert urlparse(transport.diff_requests[1].url).query == "page=2" + assert len(transport.requests) == 4 + assert all(urlparse(transport.requests[index].url).path.endswith("/merge_requests/7") for index in (0, 3)) + assert all(request.headers["PRIVATE-TOKEN"] == "offline-token" for request in transport.requests) + assert provider.get_diff_files() is files + assert len(transport.requests) == 4 + + +@pytest.mark.parametrize("project_id", [123, "123"]) +def test_numeric_project_identifier(provider_factory, project_id): + provider, transport = provider_factory([(200, [_change("a.py")], {})], "1", project_id) + assert provider.get_files() == ["a.py"] + assert urlparse(transport.diff_requests[0].url).path.endswith("/projects/123/merge_requests/7/diffs") + + +@pytest.mark.parametrize("flag", ["too_large", "collapsed"]) +def test_pruned_patch_on_later_page_is_reconstructed_from_both_blobs(provider_factory, flag): + provider, transport = provider_factory( + _pages([_change("visible.py")], [_change("pruned.py", diff="", **{flag: True})]) + ) + + files = provider.get_diff_files() + + assert [file.filename for file in files] == ["visible.py", "pruned.py"] + assert "-old" in files[1].patch and "+new" in files[1].patch + provider.get_pr_file_content.assert_has_calls([call("pruned.py", "base"), call("pruned.py", "head")]) + assert len(transport.diff_requests) == 2 + + +@pytest.mark.parametrize("count", ["1000+", "1+", "3", "0", ""]) +def test_incomplete_or_unready_collection_is_not_cached(provider_factory, count): + provider, _ = provider_factory([(200, [_change("a.py"), _change("b.py")], {})], count) + with pytest.raises(IncompleteGitLabDiffError): + provider.get_files() + assert provider.git_files is None + + +def test_absent_count_is_distinct_from_an_unready_empty_count(provider_factory): + provider, _ = provider_factory([(200, [_change("a.py")], {})], count=None) + assert provider.get_files() == ["a.py"] + + +def test_complete_empty_merge_request(provider_factory): + provider, _ = provider_factory([(200, [], {})], count="0") + assert provider.get_diff_files() == [] + + +def test_empty_patch_without_omission_flags_retains_reconstruction(provider_factory): + provider, _ = provider_factory([(200, [_change("a.py", diff="")], {})], count="1") + files = provider.get_diff_files() + assert "+new" in files[0].patch + assert "-old" in files[0].patch + + +def test_later_page_failure_does_not_cache_a_prefix_and_can_retry(provider_factory): + pages = _pages([_change("a.py")], [_change("b.py")]) + provider, transport = provider_factory([pages[0], (503, {"message": "unavailable"}, {}), *pages]) + with pytest.raises(gitlab.GitlabHttpError): + provider.get_files() + assert provider.git_files is None + assert provider.get_files() == ["a.py", "b.py"] + assert len(transport.diff_requests) == 4 + + +@pytest.mark.parametrize("method", ["get_files", "get_diff_files", "get_pr_file_paths", "get_relevant_diff"]) +def test_missing_diffs_endpoint_propagates_without_fallback(provider_factory, method): + provider, transport = provider_factory([ + (404, {"message": "original missing endpoint"}, {}), + ]) + args = ["a.py", "new"] if method == "get_relevant_diff" else [] + with pytest.raises(gitlab.GitlabHttpError, match="original missing endpoint") as error: + getattr(provider, method)(*args) + assert error.value.response_code == 404 + assert provider.git_files is None + assert provider.diff_files is None + provider.get_pr_file_content.assert_not_called() + assert [urlparse(request.url).path.rsplit("/", 1)[-1] for request in transport.requests] == ["7", "diffs"] + + +@pytest.mark.parametrize("status", [401, 403, 500]) +def test_other_errors_do_not_probe_version_or_use_legacy(provider_factory, status): + provider, transport = provider_factory([(status, {"message": "denied"}, {})]) + with pytest.raises((gitlab.GitlabHttpError, gitlab.GitlabAuthenticationError)): + provider.get_files() + assert len(transport.requests) == 2 + + +def _prepare_incremental(provider): + provider.mr_commits = [object()] + provider._incremental_kind = "review" + provider._find_anchor_note = Mock(return_value=object()) + provider.get_commit_range = Mock(return_value=[object()]) + provider.incremental = IncrementalPR(True) + provider.incremental.last_seen_commit = SimpleNamespace(sha="previous") + provider.unreviewed_files_map = {} + project = Mock() + project.repository_compare.return_value = {"diffs": [_change("a.py"), _change("b.py"), _change("target-only.py")]} + provider.gl.projects.get = Mock(return_value=project) + + +def test_incremental_membership_uses_all_pages(provider_factory): + provider, _ = provider_factory(_pages([_change("a.py")], [_change("b.py")])) + _prepare_incremental(provider) + provider._get_incremental_commits() + assert set(provider.unreviewed_files_map) == {"a.py", "b.py"} + + +def test_incremental_filter_propagates_known_incomplete_response(provider_factory): + provider, _ = provider_factory([(200, [_change("a.py")], {})], "2") + _prepare_incremental(provider) + with pytest.raises(IncompleteGitLabDiffError): + provider._get_incremental_commits() + assert provider.unreviewed_files_map == {} + + +@pytest.mark.parametrize("status", [404, 503]) +def test_incremental_filter_retains_best_effort_on_http_failure(provider_factory, status): + provider, transport = provider_factory([(status, {"message": "unavailable"}, {})]) + _prepare_incremental(provider) + provider._get_incremental_commits() + assert set(provider.unreviewed_files_map) == {"a.py", "b.py", "target-only.py"} + assert [urlparse(request.url).path.rsplit("/", 1)[-1] for request in transport.requests] == ["7", "diffs"] + + +def test_fresh_metadata_supplies_count_and_blob_refs_without_mutating_cached_mr(provider_factory): + fresh = _metadata(count="1", base_sha="fresh-base", head_sha="fresh-head") + provider, transport = provider_factory( + [(200, [_change("a.py", diff="")], {})], count="1000+", metadata_responses=_metadata_reads(fresh, fresh), + ) + cached_mr = provider.mr + cached_refs = dict(cached_mr.diff_refs) + provider.get_pr_file_content.side_effect = lambda path, ref: "old\n" if ref == "fresh-base" else "new\n" + + files = provider.get_diff_files() + + assert [file.filename for file in files] == ["a.py"] + assert "-old" in files[0].patch and "+new" in files[0].patch + assert provider.mr is cached_mr + assert provider.mr.changes_count == "1000+" + assert provider.mr.diff_refs == cached_refs + assert provider.get_pr_file_content.call_args_list == [call("a.py", "fresh-base"), call("a.py", "fresh-head")] + assert provider._expand_submodule_changes.call_args.args[1] == fresh["diff_refs"] + assert len(transport.requests) == 3 + + +@pytest.mark.parametrize("field", ["sha", "head_sha", "base_sha", "start_sha", "changes_count"]) +def test_moving_metadata_discards_pages_and_retries_once(provider_factory, field): + old = _metadata() + fresh = _metadata() + if field == "changes_count": + old[field] = "1" + elif field == "sha": + fresh[field] = "pushed-head" + else: + fresh["diff_refs"][field] = f"fresh-{field}" + provider, transport = provider_factory( + [*_pages([_change("discard-a.py")], [_change("discard-b.py")]), + *_pages([_change("a.py")], [_change("b.py")])], + metadata_responses=_metadata_reads(old, fresh, fresh, fresh), + ) + + assert provider.get_files() == ["a.py", "b.py"] + assert len(transport.diff_requests) == 4 + assert len(transport.requests) == 8 + + +@pytest.mark.parametrize("method", ["get_files", "get_diff_files", "get_pr_file_paths", "get_relevant_diff"]) +def test_continuous_movement_fails_before_use_and_can_recover(provider_factory, method): + old, middle, latest = (_metadata(count="1", head_sha=head) for head in ("old", "middle", "latest")) + provider, transport = provider_factory( + [(200, [_change("a.py")], {})] * 3, + metadata_responses=_metadata_reads(old, middle, middle, latest, latest, latest), + ) + args = ["a.py", "new"] if method == "get_relevant_diff" else [] + + with pytest.raises(IncompleteGitLabDiffError, match="changed while collecting"): + getattr(provider, method)(*args) + + assert provider.git_files is None and provider.diff_files is None + provider.get_pr_file_content.assert_not_called() + provider._expand_submodule_changes.assert_not_called() + assert len(transport.diff_requests) == 2 + assert provider.get_files() == ["a.py"] + + +@pytest.mark.parametrize("refs", [None, [], {}, {"base_sha": "base"}, {"base_sha": "", "head_sha": "head"}, + {"base_sha": "base", "head_sha": 123}]) +@pytest.mark.parametrize("method", ["get_files", "get_diff_files", "get_pr_file_paths", "get_relevant_diff"]) +def test_nonempty_collection_requires_usable_fresh_refs(provider_factory, refs, method): + metadata = {**_metadata(count="1"), "diff_refs": refs} + provider, _ = provider_factory( + [(200, [_change("a.py")], {})], metadata_responses=_metadata_reads(metadata, metadata), + ) + args = ["a.py", "new"] if method == "get_relevant_diff" else [] + with pytest.raises(IncompleteGitLabDiffError, match="diff refs are not ready"): + getattr(provider, method)(*args) + assert provider.git_files is None and provider.diff_files is None + provider.get_pr_file_content.assert_not_called() + + +def test_empty_collection_does_not_require_blob_refs(provider_factory): + metadata = {"changes_count": "0", "diff_refs": None} + provider, _ = provider_factory([(200, [], {})], metadata_responses=_metadata_reads(metadata, metadata)) + assert provider.get_diff_files() == [] + provider.get_pr_file_content.assert_not_called() + + +@pytest.mark.parametrize("after_pages", [False, True]) +def test_metadata_http_failure_never_caches_or_uses_pages(provider_factory, after_pages): + metadata = _metadata(count="1") + metadata_responses = _metadata_reads(metadata) if after_pages else [] + metadata_responses.append((503, {"message": "metadata unavailable"}, {})) + provider, transport = provider_factory([(200, [_change("a.py")], {})], metadata_responses=metadata_responses) + + with pytest.raises(gitlab.GitlabHttpError, match="metadata unavailable"): + provider.get_diff_files() + + assert provider.git_files is None and provider.diff_files is None + provider.get_pr_file_content.assert_not_called() + assert len(transport.diff_requests) == int(after_pages) + + +@pytest.mark.parametrize("flag", ["too_large", "collapsed"]) +@pytest.mark.parametrize("excluded", ["uv.lock", "ignored.py"]) +def test_flagged_excluded_file_does_not_abort_or_fetch_blobs(provider_factory, monkeypatch, flag, excluded): + monkeypatch.setitem(get_settings().ignore, "glob", ["ignored.py"]) + provider, _ = provider_factory( + _pages([_change("visible.py")], [_change(excluded, diff="", **{flag: True})]), + ) + + assert [file.filename for file in provider.get_diff_files()] == ["visible.py"] + assert all(entry.args[0] != excluded for entry in provider.get_pr_file_content.call_args_list) + + +@pytest.mark.parametrize("ref", ["base_sha", "start_sha", "head_sha"]) +def test_incremental_ref_movement_falls_back_to_fresh_full_collection(provider_factory, ref): + fresh = _metadata(**{ref: "fresh-ref"}) + changes = [_change("a.py", diff=""), _change("b.py")] + provider, transport = provider_factory( + [(200, changes, {})] * 2, metadata_responses=_metadata_reads(fresh, fresh, fresh, fresh), + ) + _prepare_incremental(provider) + provider.git_files = ["stale.py"] + provider.diff_files = [object()] + provider.unreviewed_files_map = {"stale.py": _change("stale.py")} + + provider._get_incremental_commits() + + assert provider.incremental.is_incremental is False + assert provider.git_files is None and provider.diff_files is None + assert provider.unreviewed_files_map == {} + assert [file.filename for file in provider.get_diff_files()] == ["a.py", "b.py"] + provider.get_pr_file_content.assert_has_calls([ + call("a.py", fresh["diff_refs"]["base_sha"]), call("a.py", fresh["diff_refs"]["head_sha"]), + ]) + assert len(transport.diff_requests) == 2 + assert len(transport.requests) == 6 diff --git a/tests/unittest/test_gitlab_file_read_efficiency.py b/tests/unittest/test_gitlab_file_read_efficiency.py new file mode 100644 index 0000000000..6353c61331 --- /dev/null +++ b/tests/unittest/test_gitlab_file_read_efficiency.py @@ -0,0 +1,22 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from pr_agent.git_providers.gitlab_provider import GitLabProvider + + +def test_get_pr_file_content_uses_lazy_project_handle(): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/repo" + + file_obj = MagicMock() + file_obj.decode.return_value = b"hello\n" + project = MagicMock() + project.files.get.return_value = file_obj + projects = MagicMock() + projects.get.return_value = project + provider.gl = SimpleNamespace(projects=projects) + + assert provider.get_pr_file_content("src/app.py", "deadbeef") == "hello\n" + + projects.get.assert_called_once_with("group/repo", lazy=True) + project.files.get.assert_called_once_with("src/app.py", "deadbeef") diff --git a/tests/unittest/test_gitlab_fixed_threads.py b/tests/unittest/test_gitlab_fixed_threads.py new file mode 100644 index 0000000000..b13cde06f3 --- /dev/null +++ b/tests/unittest/test_gitlab_fixed_threads.py @@ -0,0 +1,230 @@ +"""Unit tests for gitlab.auto_resolve_fixed_inline_threads.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from pr_agent.config_loader import get_settings +from pr_agent.git_providers.gitlab_provider import ( + GitLabProvider, + _eligible_own_inline_thread, + _flagged_line_removed, + _removed_lines_from_patch, +) + +from ._settings_helpers import restore_settings, snapshot_settings + +BOT_ID = 71 +OLD_SHA = "a" * 40 +HEAD_SHA = "b" * 40 +AGENT_BODY = "**Suggestion:** drop the key " + +PATCH = """\ +@@ -1,3 +1,4 @@ + ctx +-old ++new one ++new two + ctx +@@ -10,2 +10,3 @@ + ctx ++added + ctx +""" + +INSERT_ONLY_PATCH = """\ +@@ -1,2 +1,3 @@ + ctx ++inserted + ctx +""" + +NO_NEWLINE_PATCH = """\ +@@ -1,3 +1,3 @@ + ctx +-old +\\ No newline at end of file ++new +\\ No newline at end of file +""" + +DOUBLE_DASH_PATCH = """\ +@@ -1,3 +1,3 @@ + ctx +---flag + ctx +""" + + +def _discussion(body=AGENT_BODY, path="app.py", line=4, head_sha=OLD_SHA, + resolved=False, author_id=BOT_ID): + note = { + "resolved": resolved, + "resolvable": True, + "body": body, + "author": {"id": author_id}, + "position": { + "position_type": "text", + "new_path": path, + "new_line": line, + "head_sha": head_sha, + }, + } + return SimpleNamespace(id="d1", attributes={"notes": [note]}, + resolved=False, save=MagicMock()) + + +def _provider(discussions, diffs) -> GitLabProvider: + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "owner/repo" + provider.id_mr = 7 + provider.mr = MagicMock() + provider.mr.diff_refs = {"head_sha": HEAD_SHA} + provider.mr.discussions.list.return_value = discussions + provider._get_own_user_id = MagicMock(return_value=BOT_ID) + provider.gl = MagicMock() + provider.gl.projects.get.return_value.repository_compare.return_value = {"diffs": diffs} + return provider + + +@pytest.fixture(autouse=True) +def flag(): + snapshot = snapshot_settings(["gitlab.auto_resolve_fixed_inline_threads"]) + get_settings().set("gitlab.auto_resolve_fixed_inline_threads", True) + yield + restore_settings(snapshot) + + +def test_removed_lines_from_patch() -> None: + assert _removed_lines_from_patch(PATCH) == {2} + assert _removed_lines_from_patch(INSERT_ONLY_PATCH) == set() + + +def test_no_newline_marker_is_not_a_file_line() -> None: + # "\ No newline at end of file" must not advance the base line counter. + assert _removed_lines_from_patch(NO_NEWLINE_PATCH) == {2} + + +def test_double_dash_content_line_is_a_removal() -> None: + # A removed source line "--flag" is encoded as "---flag" inside the hunk. + assert _removed_lines_from_patch(DOUBLE_DASH_PATCH) == {2} + + +def test_thread_on_removed_line_resolves() -> None: + discussion = _discussion(line=2) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + assert provider.reconcile_code_suggestion_threads() == 1 + assert discussion.resolved is True + discussion.save.assert_called_once() + + +def test_compare_uses_straight_diff() -> None: + # The default merge-base comparison would report lines dropped by a rebase + # as removed; the sweep must compare the recorded head to the current head + # directly. + discussion = _discussion(line=2) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + provider.gl.projects.get.return_value.repository_compare.assert_called_once_with( + OLD_SHA, HEAD_SHA, straight=True) + + +def test_thread_on_untouched_line_stays_open() -> None: + discussion = _discussion(line=3) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + assert discussion.resolved is False + discussion.save.assert_not_called() + + +def test_insertion_at_flagged_position_does_not_resolve() -> None: + # A line inserted where the comment used to point must not resolve the + # thread: coordinates belong to the comment's head, not the current one. + discussion = _discussion(line=2) + provider = _provider([discussion], [{"old_path": "app.py", "diff": INSERT_ONLY_PATCH}]) + provider.reconcile_code_suggestion_threads() + assert discussion.resolved is False + + +def test_thread_on_current_head_is_not_compared() -> None: + discussion = _discussion(line=2, head_sha=HEAD_SHA) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + provider.gl.projects.get.return_value.repository_compare.assert_not_called() + assert discussion.resolved is False + + +def test_ineligible_threads_skip_compare() -> None: + # Human and already-resolved threads must not cost a repository_compare call. + foreign = _discussion(line=2, body="a human comment") + other_author = _discussion(line=2, author_id=999) + resolved_thread = _discussion(line=2, resolved=True) + provider = _provider([foreign, other_author, resolved_thread], + [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + provider.gl.projects.get.return_value.repository_compare.assert_not_called() + for d in (foreign, other_author, resolved_thread): + d.save.assert_not_called() + + +def test_object_shaped_compare_response_is_handled() -> None: + discussion = _discussion(line=2) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + provider.gl.projects.get.return_value.repository_compare.return_value = SimpleNamespace( + diffs=[SimpleNamespace(new_path="app.py", old_path="app.py", diff=PATCH)]) + provider.reconcile_code_suggestion_threads() + assert discussion.resolved is True + + +def test_disabled_flag_is_noop() -> None: + get_settings().set("gitlab.auto_resolve_fixed_inline_threads", False) + discussion = _discussion(line=2) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + provider.mr.discussions.list.assert_not_called() + assert discussion.resolved is False + + +def test_non_agent_and_other_author_threads_stay_open() -> None: + foreign = _discussion(line=2, body="a human comment") + other_author = _discussion(line=2, author_id=999) + provider = _provider([foreign, other_author], [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + assert foreign.resolved is False + assert other_author.resolved is False + + +def test_thread_eligibility_guards() -> None: + assert _eligible_own_inline_thread(_discussion(resolved=True), BOT_ID) is None + assert _eligible_own_inline_thread(_discussion(body="human"), BOT_ID) is None + position = _eligible_own_inline_thread(_discussion(line=2), BOT_ID) + assert position is not None + assert _flagged_line_removed(position, {"app.py": {2}}) + assert not _flagged_line_removed(position, {"app.py": {3}}) + + +def test_deletion_anchored_thread_stays_open() -> None: + # old_line is a coordinate in the MR base, not the comment head: a head-side + # removal at the same number must not resolve a deletion-anchored thread. + position = {"position_type": "text", "new_path": "app.py", "old_path": "app.py", + "old_line": 2, "head_sha": OLD_SHA} + assert not _flagged_line_removed(position, {"app.py": {2}}) + + +def test_repeat_sweep_costs_no_api_calls() -> None: + # PRCodeSuggestions.run() and publish_code_suggestions share one provider; + # the second sweep must not list discussions again. + discussion = _discussion(line=3) + provider = _provider([discussion], [{"old_path": "app.py", "diff": PATCH}]) + provider.reconcile_code_suggestion_threads() + provider.reconcile_code_suggestion_threads() + provider.mr.discussions.list.assert_called_once() + + +def test_supports_code_suggestion_state() -> None: + # PRCodeSuggestions.run() calls reconcile_code_suggestion_threads through + # the generic provider contract when this reports support. + provider = _provider([], []) + assert provider.supports_code_suggestion_state() is True + assert provider.reconcile_code_suggestion_threads() == 0 diff --git a/tests/unittest/test_gitlab_language_cache.py b/tests/unittest/test_gitlab_language_cache.py new file mode 100644 index 0000000000..8c68f8d3cb --- /dev/null +++ b/tests/unittest/test_gitlab_language_cache.py @@ -0,0 +1,35 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from pr_agent.git_providers.gitlab_provider import GitLabProvider + + +@pytest.mark.parametrize("languages", [{"Python": 75.0, "JavaScript": 25.0}, {}]) +def test_get_languages_reuses_result(languages): + provider = object.__new__(GitLabProvider) + provider.id_project = "group/project" + project = SimpleNamespace(languages=MagicMock(return_value=languages)) + provider.gl = SimpleNamespace(projects=SimpleNamespace(get=MagicMock(return_value=project))) + + first = provider.get_languages() + second = provider.get_languages() + + assert first == languages + assert second is first + provider.gl.projects.get.assert_called_once_with("group/project") + project.languages.assert_called_once_with() + + +def test_get_languages_does_not_cache_failures(): + provider = object.__new__(GitLabProvider) + provider.id_project = "group/project" + project = SimpleNamespace(languages=MagicMock(side_effect=[RuntimeError("temporary"), {"Python": 100.0}])) + provider.gl = SimpleNamespace(projects=SimpleNamespace(get=MagicMock(return_value=project))) + + with pytest.raises(RuntimeError, match="temporary"): + provider.get_languages() + + assert provider.get_languages() == {"Python": 100.0} + assert project.languages.call_count == 2 diff --git a/tests/unittest/test_gitlab_provider.py b/tests/unittest/test_gitlab_provider.py index 1e06cd786c..a91c1cead2 100644 --- a/tests/unittest/test_gitlab_provider.py +++ b/tests/unittest/test_gitlab_provider.py @@ -1,12 +1,13 @@ from datetime import datetime -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch import pytest from gitlab import Gitlab -from gitlab.exceptions import GitlabGetError +from gitlab.exceptions import GitlabAuthenticationError, GitlabError, GitlabGetError from gitlab.v4.objects import ProjectFile, ProjectMergeRequest, ProjectMergeRequestManager +from requests.exceptions import RequestException -from pr_agent.algo.utils import PRCodeSuggestionsIdentity, PRReviewHeader, PRReviewIdentity +from pr_agent.algo.comment_identity import PRCodeSuggestionsIdentity, PRReviewHeader, PRReviewIdentity from pr_agent.git_providers.git_provider import IncrementalPR from pr_agent.git_providers.gitlab_provider import ( GitLabProvider, @@ -85,6 +86,17 @@ def gitlab_provider(self, mock_gitlab_client, mock_project): provider = GitLabProvider("https://gitlab.com/test/repo/-/merge_requests/1") provider.gl = mock_gitlab_client provider.id_project = "test/repo" + + def _current_mr_metadata(*_args, **_kwargs): + diff_refs = getattr(provider.mr, "diff_refs", None) + changes = mock_gitlab_client.http_list.return_value + return { + "sha": "current-mr", + "diff_refs": dict(diff_refs) if isinstance(diff_refs, dict) else {}, + "changes_count": str(len(changes)) if isinstance(changes, list) else None, + } + + mock_gitlab_client.http_get.side_effect = _current_mr_metadata return provider def test_get_pr_file_content_success(self, gitlab_provider, mock_project): @@ -117,12 +129,40 @@ def test_get_pr_file_content_file_not_found(self, gitlab_provider, mock_project) mock_project.files.get.assert_called_once_with("CHANGELOG.md", "main") def test_get_pr_file_content_other_exception(self, gitlab_provider, mock_project): - mock_project.files.get.side_effect = Exception("Network error") + mock_project.files.get.side_effect = RequestException("Network error") content = gitlab_provider.get_pr_file_content("CHANGELOG.md", "main") assert content == "" + def test_get_pr_file_content_strict_missing_file_is_empty(self, gitlab_provider, mock_project): + mock_project.files.get.side_effect = GitlabGetError("404 Not Found", response_code=404) + + content = gitlab_provider.get_pr_file_content("CHANGELOG.md", "main", propagate_errors=True) + + assert content == "" + + @pytest.mark.parametrize( + "error", + [ + GitlabGetError("500 Server Error", response_code=500, response_body=b"upstream failure"), + UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"), + RuntimeError("transport failed"), + ], + ) + def test_get_pr_file_content_strict_reraises_original_error(self, gitlab_provider, mock_project, error): + mock_file = MagicMock(ProjectFile) + if isinstance(error, UnicodeDecodeError): + mock_file.decode.side_effect = error + mock_project.files.get.return_value = mock_file + else: + mock_project.files.get.side_effect = error + + with pytest.raises(type(error)) as exc_info: + gitlab_provider.get_pr_file_content("CHANGELOG.md", "main", propagate_errors=True) + + assert exc_info.value is error + def test_get_repo_file_content_loads_from_mr_target_branch(self, gitlab_provider, mock_gitlab_client, mock_project): mock_project.default_branch = "main" gitlab_provider.mr = MagicMock(target_branch="release-1.0") @@ -278,9 +318,140 @@ def test_get_gitmodules_map_parsing(self, gitlab_provider, mock_project): "libs/b": "git@gitlab.com:b.git", } + @staticmethod + def _gitmodules_file(url, path="libs/a"): + file_obj = MagicMock(ProjectFile) + file_obj.decode.return_value = f"[submodule \"{path}\"]\n path = {path}\n url = {url}\n" + return file_obj + + @staticmethod + def _mr(diff_refs=None, source_project_id=1): + mr = MagicMock() + mr.source_branch = "feature" + mr.target_branch = "main" + mr.diff_refs = diff_refs + mr.source_project_id = source_project_id + return mr + + def test_get_gitmodules_map_reads_mr_head_sha(self, gitlab_provider, mock_project): + gitlab_provider.mr = self._mr(diff_refs={"head_sha": "h1", "base_sha": "b1"}, source_project_id=99) + mock_project.id = 1 + # The source branch moved on since the MR diff was fetched; its content must not be used. + mock_project.files.get.side_effect = lambda file_path, ref: { + "h1": self._gitmodules_file("../new/a.git"), + "feature": self._gitmodules_file("../newer/a.git"), + "b1": self._gitmodules_file("../old/a.git"), + "main": self._gitmodules_file("../old/a.git"), + }[ref] + gitlab_provider.gl.projects.get.return_value = mock_project + + assert gitlab_provider._get_gitmodules_map() == {"libs/a": "../new/a.git"} + mock_project.files.get.assert_called_once_with(file_path=".gitmodules", ref="h1") + # The fork project is not looked up when the head commit could be read from the target project. + assert all(call.args[0] != 99 for call in gitlab_provider.gl.projects.get.call_args_list) + + def test_get_gitmodules_map_uses_provided_snapshot_refs(self, gitlab_provider, mock_project): + gitlab_provider.mr = self._mr( + diff_refs={"head_sha": "cached-head", "base_sha": "cached-base"}, source_project_id=99 + ) + mock_project.id = 1 + mock_project.files.get.side_effect = lambda file_path, ref: { + "snapshot-head": self._gitmodules_file("../snapshot/a.git"), + "cached-head": self._gitmodules_file("../cached/a.git"), + }[ref] + gitlab_provider.gl.projects.get.return_value = mock_project + + result = gitlab_provider._get_gitmodules_map( + {"head_sha": "snapshot-head", "base_sha": "snapshot-base"} + ) + + assert result == {"libs/a": "../snapshot/a.git"} + mock_project.files.get.assert_called_once_with(file_path=".gitmodules", ref="snapshot-head") + + def test_get_gitmodules_map_falls_back_to_source_branch_without_diff_refs(self, gitlab_provider, mock_project): + gitlab_provider.mr = self._mr(diff_refs=None, source_project_id=mock_project.id) + mock_project.files.get.side_effect = lambda file_path, ref: { + "feature": self._gitmodules_file("../new/a.git"), + "main": self._gitmodules_file("../old/a.git"), + }[ref] + gitlab_provider.gl.projects.get.return_value = mock_project + + assert gitlab_provider._get_gitmodules_map() == {"libs/a": "../new/a.git"} + + @pytest.mark.parametrize("available_ref,expected", [ + ("b1", "../base/a.git"), + ("main", "../old/a.git"), + ]) + def test_get_gitmodules_map_falls_back_to_base_sha_then_target_branch(self, gitlab_provider, mock_project, + available_ref, expected): + gitlab_provider.mr = self._mr(diff_refs={"head_sha": "h1", "base_sha": "b1"}, source_project_id=mock_project.id) + files = {"b1": "../base/a.git", "main": "../old/a.git"} + + def _files_get(file_path, ref): + if ref == available_ref: + return self._gitmodules_file(files[ref]) + raise GitlabGetError("404 File Not Found") + + mock_project.files.get.side_effect = _files_get + gitlab_provider.gl.projects.get.return_value = mock_project + + assert gitlab_provider._get_gitmodules_map() == {"libs/a": expected} + + def test_get_gitmodules_map_reads_fork_source_project(self, gitlab_provider, mock_project): + gitlab_provider.mr = self._mr(diff_refs=None, source_project_id=99) + mock_project.id = 1 + fork = MagicMock() + fork.id = 99 + fork.files.get.return_value = self._gitmodules_file("../fork/a.git") + mock_project.files.get.return_value = self._gitmodules_file("../old/a.git") + gitlab_provider.gl.projects.get.side_effect = lambda pid, **kw: fork if str(pid) == "99" else mock_project + + assert gitlab_provider._get_gitmodules_map() == {"libs/a": "../fork/a.git"} + fork.files.get.assert_called_once_with(file_path=".gitmodules", ref="feature") + mock_project.files.get.assert_not_called() + + def test_get_gitmodules_map_failed_fork_lookup_skips_source_read(self, gitlab_provider, mock_project): + gitlab_provider.mr = self._mr(diff_refs=None, source_project_id=99) + mock_project.id = 1 + # The target project happens to have a branch named like the fork's source branch. + mock_project.files.get.side_effect = lambda file_path, ref: { + "feature": self._gitmodules_file("../unrelated/a.git"), + "main": self._gitmodules_file("../old/a.git"), + }[ref] + + def _projects_get(pid, **kw): + if str(pid) == "99": + raise GitlabGetError("404 Project Not Found") + return mock_project + + gitlab_provider.gl.projects.get.side_effect = _projects_get + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_logger: + assert gitlab_provider._get_gitmodules_map() == {"libs/a": "../old/a.git"} + + mock_project.files.get.assert_called_once_with(file_path=".gitmodules", ref="main") + assert "99" in mock_logger.return_value.warning.call_args.args[0] + + def test_expand_submodule_changes_uses_mr_head_url(self, gitlab_provider, mock_project): + gitlab_provider.id_project = "group/repo" + gitlab_provider.mr = self._mr(diff_refs={"head_sha": "h1", "base_sha": "b1"}, source_project_id=mock_project.id) + mock_project.files.get.side_effect = lambda file_path, ref: { + "h1": self._gitmodules_file("../new/a.git", path="src/lib_a"), + "main": self._gitmodules_file("../old/a.git", path="src/lib_a"), + }[ref] + gitlab_provider.gl.projects.get.return_value = mock_project + settings = MagicMock() + settings.get.side_effect = lambda key, default=None: {"GITLAB.EXPAND_SUBMODULE_DIFFS": True}.get(key, default) + + with patch("pr_agent.git_providers.gitlab_provider.get_settings", return_value=settings), \ + patch.object(gitlab_provider, "_compare_submodule", return_value=[]) as m_cmp: + gitlab_provider._expand_submodule_changes([self._submodule_bump()]) + + m_cmp.assert_called_once_with("group/new/a", "aaa1111", "bbb2222") + def test_project_by_path_requires_exact_match(self, gitlab_provider): gitlab_provider.gl.projects.get.reset_mock() - gitlab_provider.gl.projects.get.side_effect = Exception("not found") + gitlab_provider.gl.projects.get.side_effect = GitlabGetError("not found") fake = MagicMock() fake.id = "mismatched-project-id" fake.path_with_namespace = "other/group/repo" @@ -295,6 +466,87 @@ def test_project_by_path_requires_exact_match(self, gitlab_provider): assert list_kwargs["membership"] is True assert all(call.args[0] != fake.id for call in gitlab_provider.gl.projects.get.call_args_list) + @pytest.mark.parametrize("url,base,expected", [ + ("https://gitlab.com/group/repo.git", None, "group/repo"), + ("git@gitlab.com:group/sub/repo.git", None, "group/sub/repo"), + ("ssh://git@gitlab.com/group/repo.git", None, "group/repo"), + # Relative URLs resolve against the superproject path. + ("../../../../libs/lib_a.git", "group/subgroup/nested/repo", "libs/lib_a"), + ("../../libs/lib_a.git", "group/subgroup/nested/repo", "group/subgroup/libs/lib_a"), + ("../lib_b.git", "group/sub/repo", "group/sub/lib_b"), + ("./nested/lib_c.git", "group/repo", "group/repo/nested/lib_c"), + # Query/fragment are dropped for relative URLs just like for absolute ones. + ("../lib_b.git?ref=main", "group/repo", "group/lib_b"), + ("https://gitlab.com/group/repo.git#main", None, "group/repo"), + # Relative URL without a superproject to resolve against. + ("../lib_b.git", None, None), + # Relative URL that climbs past the instance root. + ("../../../lib_b.git", "group/repo", None), + ]) + def test_url_to_project_path(self, gitlab_provider, url, base, expected): + assert gitlab_provider._url_to_project_path(url, base) == expected + + def test_superproject_path_prefers_id_project_path(self, gitlab_provider): + gitlab_provider.id_project = "group/sub/repo" + gitlab_provider.gl.projects.get.reset_mock() + + assert gitlab_provider._superproject_path() == "group/sub/repo" + gitlab_provider.gl.projects.get.assert_not_called() + + def test_superproject_path_resolves_numeric_project_id(self, gitlab_provider, mock_project): + gitlab_provider.id_project = "42" + mock_project.path_with_namespace = "group/sub/repo" + gitlab_provider.gl.projects.get.return_value = mock_project + + assert gitlab_provider._superproject_path() == "group/sub/repo" + + def test_superproject_path_logs_lookup_failure(self, gitlab_provider): + gitlab_provider.id_project = "42" + gitlab_provider.gl.projects.get.side_effect = GitlabGetError("401 Unauthorized") + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_logger: + assert gitlab_provider._superproject_path() is None + + warning = mock_logger.return_value.warning + warning.assert_called_once() + assert "42" in warning.call_args.args[0] + assert "401 Unauthorized" in warning.call_args.args[0] + + def _submodule_bump(self, path="src/lib_a"): + return {"new_path": path, "old_path": path, + "diff": "-Subproject commit aaa1111\n+Subproject commit bbb2222\n", + "new_file": False, "deleted_file": False, "renamed_file": False} + + def test_expand_submodule_changes_resolves_relative_url(self, gitlab_provider): + gitlab_provider.id_project = "group/subgroup/nested/repo" + settings = MagicMock() + settings.get.side_effect = lambda key, default=None: {"GITLAB.EXPAND_SUBMODULE_DIFFS": True}.get(key, default) + sub_diffs = [{"old_path": "src/a.c", "new_path": "src/a.c", "diff": "@@ -1 +1 @@\n-x\n+y\n"}] + + with patch("pr_agent.git_providers.gitlab_provider.get_settings", return_value=settings), \ + patch.object(gitlab_provider, "_get_gitmodules_map", + return_value={"src/lib_a": "../../../../libs/lib_a.git"}), \ + patch.object(gitlab_provider, "_compare_submodule", return_value=sub_diffs) as m_cmp: + out = gitlab_provider._expand_submodule_changes([self._submodule_bump()]) + + m_cmp.assert_called_once_with("libs/lib_a", "aaa1111", "bbb2222") + assert [c["new_path"] for c in out] == ["src/lib_a", "src/lib_a/src/a.c"] + + def test_expand_submodule_changes_skips_unresolvable_relative_url(self, gitlab_provider): + gitlab_provider.id_project = "group/repo" + settings = MagicMock() + settings.get.side_effect = lambda key, default=None: {"GITLAB.EXPAND_SUBMODULE_DIFFS": True}.get(key, default) + changes = [self._submodule_bump()] + + with patch("pr_agent.git_providers.gitlab_provider.get_settings", return_value=settings), \ + patch.object(gitlab_provider, "_get_gitmodules_map", + return_value={"src/lib_a": "../../../libs/lib_a.git"}), \ + patch.object(gitlab_provider, "_compare_submodule") as m_cmp: + out = gitlab_provider._expand_submodule_changes(changes) + + m_cmp.assert_not_called() + assert out == changes + def test_compare_submodule_cached(self, gitlab_provider): proj = MagicMock() proj.repository_compare.return_value = {"diffs": [{"diff": "d"}]} @@ -413,7 +665,7 @@ def test_publish_comment_as_thread_creates_a_discussion(self, gitlab_provider): def test_publish_comment_as_thread_falls_back_to_note_on_error(self, gitlab_provider): gitlab_provider.mr = MagicMock() - gitlab_provider.mr.discussions.create.side_effect = Exception("gitlab api error") + gitlab_provider.mr.discussions.create.side_effect = GitlabError("gitlab api error") result = gitlab_provider.publish_comment("the review", as_thread=True) # Thread creation failed, so publishing must not raise and must fall back to a plain note. @@ -421,7 +673,7 @@ def test_publish_comment_as_thread_falls_back_to_note_on_error(self, gitlab_prov assert result is gitlab_provider.mr.notes.create.return_value @pytest.mark.parametrize("break_response", [ - lambda mr: setattr(mr.notes.get, 'side_effect', Exception("gitlab api error")), + lambda mr: setattr(mr.notes.get, 'side_effect', GitlabError("gitlab api error")), lambda mr: setattr(mr.discussions.create.return_value, 'attributes', {'notes': []}), lambda mr: setattr(mr.discussions.create.return_value, 'attributes', {}), ]) @@ -723,7 +975,7 @@ def test_unresolve_comment_thread_ignores_unrelated_discussions(self, gitlab_pro def test_unresolve_comment_thread_soft_fails(self, gitlab_provider): # A GitLab API error while reopening must not raise. gitlab_provider.mr = MagicMock() - gitlab_provider.mr.discussions.list.side_effect = Exception("gitlab api error") + gitlab_provider.mr.discussions.list.side_effect = GitlabError("gitlab api error") gitlab_provider.unresolve_comment_thread(MagicMock(id=1)) # must not raise @@ -760,7 +1012,7 @@ def test_resolve_comment_thread_ignores_unrelated_discussions(self, gitlab_provi def test_resolve_comment_thread_soft_fails(self, gitlab_provider): # A GitLab API error while resolving must not raise. gitlab_provider.mr = MagicMock() - gitlab_provider.mr.discussions.list.side_effect = Exception("gitlab api error") + gitlab_provider.mr.discussions.list.side_effect = GitlabError("gitlab api error") assert gitlab_provider.resolve_comment_thread(1) is False @@ -863,7 +1115,7 @@ def test_resolve_outdated_inline_threads_skips_when_identity_or_head_is_unknown( def test_resolve_outdated_inline_threads_continues_past_a_failing_thread(self, gitlab_provider): failing = _thread([_thread_note()], discussion_id='failing') - failing.save.side_effect = Exception("gitlab api error") + failing.save.side_effect = GitlabError("gitlab api error") outdated = _thread([_thread_note()], discussion_id='outdated') self._prepare_outdated_cleanup(gitlab_provider, [failing, outdated]) @@ -873,7 +1125,7 @@ def test_resolve_outdated_inline_threads_continues_past_a_failing_thread(self, g def test_resolve_outdated_inline_threads_soft_fails(self, gitlab_provider): self._prepare_outdated_cleanup(gitlab_provider, []) - gitlab_provider.mr.discussions.list.side_effect = Exception("gitlab api error") + gitlab_provider.mr.discussions.list.side_effect = GitlabError("gitlab api error") self._run_outdated_cleanup(gitlab_provider) @@ -998,7 +1250,7 @@ def test_publish_labels_drops_pending_diff_when_save_fails(self, gitlab_provider # save() clears pending attributes itself, but only when it succeeds. If they # survive a failure, the next save() on this MR — publish_description() runs # one moments later — resends the label diff. - gitlab_provider.mr = self._real_mr(["bug"], update_error=RuntimeError("network blip")) + gitlab_provider.mr = self._real_mr(["bug"], update_error=RequestException("network blip")) gitlab_provider.publish_labels(["review effort 3/5"]) @@ -1025,7 +1277,7 @@ def test_get_pr_labels_with_update_falls_back_to_cache_on_failure(self, gitlab_p # because publish_labels diffs against that same snapshot, so a failed refresh # narrows what the update touches instead of clobbering labels. gitlab_provider.mr = MagicMock(labels=["cached"]) - gitlab_provider._get_merge_request = MagicMock(side_effect=RuntimeError("boom")) + gitlab_provider._get_merge_request = MagicMock(side_effect=GitlabError("boom")) assert gitlab_provider.get_pr_labels(update=True) == ["cached"] @@ -1215,6 +1467,16 @@ def gitlab_provider(self, mock_gitlab_client, mock_project): provider.mr = MagicMock() provider.mr.web_url = "https://gitlab.com/test/repo/-/merge_requests/1" provider.mr.diff_refs = {"base_sha": "base", "head_sha": "head", "start_sha": "base"} + + def _current_mr_metadata(*_args, **_kwargs): + changes = mock_gitlab_client.http_list.return_value + return { + "sha": "current-mr", + "diff_refs": dict(provider.mr.diff_refs), + "changes_count": str(len(changes)) if isinstance(changes, list) else None, + } + + mock_gitlab_client.http_get.side_effect = _current_mr_metadata return provider @staticmethod @@ -1268,11 +1530,9 @@ def test_get_incremental_commits_picks_commits_after_review(self, gitlab_provide "new_file": True, "deleted_file": False, "renamed_file": False}, ] } - # mr.changes() is intersected with repository_compare to exclude files brought in - # via a merge from the target branch. Here both files are part of the MR. - gitlab_provider.mr.changes.return_value = { - "changes": [{"new_path": "a.py"}, {"new_path": "b.py"}] - } + # Intersect MR diffs with repository_compare to exclude files merged from the target branch. + # Include both fixture files in the MR's own changes. + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}, {"new_path": "b.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True)) @@ -1317,93 +1577,16 @@ def test_get_files_uses_incremental_set_when_active(self, gitlab_provider): gitlab_provider.unreviewed_files_map = {"a.py": {"new_path": "a.py"}} assert gitlab_provider.get_files() == ["a.py"] + gitlab_provider.gl.http_list.assert_not_called() gitlab_provider.mr.changes.assert_not_called() - def test_get_files_falls_back_to_mr_changes_when_not_incremental(self, gitlab_provider): + def test_get_files_fetches_mr_diffs_when_not_incremental(self, gitlab_provider): gitlab_provider.incremental = IncrementalPR(False) gitlab_provider.git_files = None - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "x.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "x.py"}] assert gitlab_provider.get_files() == ["x.py"] - def test_get_files_retries_raw_diffs_when_changes_overflow(self, gitlab_provider): - gitlab_provider.git_files = None - gitlab_provider.mr.changes.side_effect = [ - {"changes": [{"new_path": "visible.py"}], "overflow": True}, - { - "changes": [ - {"new_path": "visible.py"}, - {"new_path": "hidden.py"}, - ], - "overflow": False, - }, - ] - - assert gitlab_provider.get_files() == ["visible.py", "hidden.py"] - assert gitlab_provider.mr.changes.call_args_list == [ - call(), - call(access_raw_diffs=True), - ] - - def test_get_diff_files_retries_raw_diffs_when_changes_overflow(self, gitlab_provider): - change = { - "old_path": "visible.py", - "new_path": "visible.py", - "diff": "@@ -1 +1 @@\n-old\n+new\n", - "new_file": False, - "deleted_file": False, - "renamed_file": False, - } - hidden_change = {**change, "old_path": "hidden.py", "new_path": "hidden.py"} - gitlab_provider.mr.changes.side_effect = [ - {"changes": [change], "overflow": True}, - {"changes": [change, hidden_change], "overflow": False}, - ] - gitlab_provider.get_pr_file_content = MagicMock(return_value="") - - diff_files = gitlab_provider.get_diff_files() - - assert [file.filename for file in diff_files] == ["visible.py", "hidden.py"] - assert gitlab_provider.mr.changes.call_args_list == [ - call(), - call(access_raw_diffs=True), - ] - - def test_incremental_scope_retries_raw_diffs_when_changes_overflow( - self, gitlab_provider, mock_project - ): - gitlab_provider.mr.notes.list.return_value = [ - self._make_note(7, "## PR Reviewer Guide 🔍\nbody", "2024-05-01T10:00:00Z"), - ] - gitlab_provider.mr.commits.return_value = [ - self._make_commit("c1", "2024-05-01T11:00:00Z"), - self._make_commit("c0", "2024-05-01T09:00:00Z"), - ] - mock_project.repository_compare.return_value = { - "diffs": [ - {"new_path": "visible.py"}, - {"new_path": "hidden.py"}, - ] - } - gitlab_provider.mr.changes.side_effect = [ - {"changes": [{"new_path": "visible.py"}], "overflow": True}, - { - "changes": [ - {"new_path": "visible.py"}, - {"new_path": "hidden.py"}, - ], - "overflow": False, - }, - ] - - gitlab_provider.get_incremental_commits(IncrementalPR(True)) - - assert set(gitlab_provider.unreviewed_files_map) == {"visible.py", "hidden.py"} - assert gitlab_provider.mr.changes.call_args_list == [ - call(), - call(access_raw_diffs=True), - ] - def test_get_previous_review_returns_most_recent_match(self, gitlab_provider): # GitLab returns notes in created_at-DESC order. The helper relies on that order # (no local sort) — the unrelated newest note must be skipped, the newer matching @@ -1439,9 +1622,9 @@ def test_get_previous_review_accepts_custom_heading_with_stable_identity(self, g def test_master_merge_files_are_excluded_from_incremental_scope(self, gitlab_provider, mock_project): # Reproduction of the MR !1115 bug: user ran `git merge master` on the feature branch, # which brought CI/config changes into the branch via a merge commit. Those files are - # NOT part of mr.changes() (the MR's actual contribution against its merge-base), but + # NOT part of the MR diffs (the MR's actual contribution against its merge-base), but # repository_compare(last_seen, head) walks through the merge and surfaces them. - # The fix intersects with mr.changes() to drop these "phantom" files. + # Intersect with the MR diffs to drop these "phantom" files. gitlab_provider.mr.notes.list.return_value = [ self._make_note(7, "## PR Reviewer Guide 🔍\nbody", "2024-05-01T10:00:00Z"), ] @@ -1460,11 +1643,9 @@ def test_master_merge_files_are_excluded_from_incremental_scope(self, gitlab_pro "new_file": False, "deleted_file": False, "renamed_file": False}, ] } - # mr.changes() returns only the MR's actual contribution; .gitlab-ci.yml is absent + # Include only the MR's actual contribution in its diffs; exclude .gitlab-ci.yml # because the change to it lives in the target branch already. - gitlab_provider.mr.changes.return_value = { - "changes": [{"new_path": "src/feature.js"}] - } + gitlab_provider.gl.http_list.return_value = [{"new_path": "src/feature.js"}] gitlab_provider.get_incremental_commits(IncrementalPR(True)) @@ -1564,7 +1745,7 @@ def test_incremental_kind_suggestions_anchors_on_suggestion_note(self, gitlab_pr "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -1588,7 +1769,7 @@ def test_object_shaped_compare_diffs_are_normalized_to_dicts(self, gitlab_provid obj_diff = SimpleNamespace(new_path="a.py", old_path="a.py", diff="@@ ... @@", new_file=False, deleted_file=False, renamed_file=False) mock_project.repository_compare.return_value = SimpleNamespace(diffs=[obj_diff]) - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True)) @@ -1618,7 +1799,7 @@ def test_anchor_note_from_another_user_is_skipped(self, gitlab_provider, mock_pr "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -1630,7 +1811,7 @@ def test_anchor_note_from_another_user_is_skipped(self, gitlab_provider, mock_pr def test_anchor_author_check_fails_open_when_user_unresolvable(self, gitlab_provider, mock_project): # Job-token auth can't resolve the current user; anchoring must stay prefix-only # rather than breaking incremental runs. - gitlab_provider.gl.auth.side_effect = Exception("401 insufficient scope") + gitlab_provider.gl.auth.side_effect = GitlabAuthenticationError("401 insufficient scope") gitlab_provider.mr.notes.list.return_value = [ self._make_note(8, "## PR Code Suggestions ✨\ntable", "2026-05-15T10:00:00Z", author={"id": 999}), @@ -1643,7 +1824,7 @@ def test_anchor_author_check_fails_open_when_user_unresolvable(self, gitlab_prov "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -1683,6 +1864,26 @@ def test_get_incremental_commits_invalidates_cached_diff_files(self, gitlab_prov assert gitlab_provider.diff_files is None + def test_get_incremental_commits_reloads_commits_for_each_public_setup(self, gitlab_provider, mock_project): + gitlab_provider.mr.notes.list.return_value = [ + self._make_note(7, "## PR Reviewer Guide 🔍\nbody", "2024-05-01T10:00:00Z"), + ] + gitlab_provider.mr.commits.return_value = [ + self._make_commit("c1", "2024-05-01T11:00:00Z"), + self._make_commit("c0", "2024-05-01T09:00:00Z"), + ] + mock_project.repository_compare.return_value = { + "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", + "new_file": False, "deleted_file": False, "renamed_file": False}], + } + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] + + gitlab_provider.get_incremental_commits(IncrementalPR(True)) + gitlab_provider.get_incremental_commits(IncrementalPR(True)) + + assert gitlab_provider.mr.commits.call_count == 2 + assert mock_project.repository_compare.call_count == 2 + def test_incremental_suggestions_anchor_advances_with_in_place_edits(self, gitlab_provider, mock_project): # Default /improve config (persistent_comment=true, commitable_code_suggestions=false) # EDITS the "## PR Code Suggestions ✨" summary note in place on every run, so its @@ -1703,7 +1904,7 @@ def test_incremental_suggestions_anchor_advances_with_in_place_edits(self, gitla "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -1738,7 +1939,7 @@ def test_incremental_suggestions_uses_newest_stable_or_legacy_anchor( "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -1773,7 +1974,7 @@ def test_incremental_suggestions_uses_latest_activity_across_stable_and_legacy_a "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -1833,7 +2034,7 @@ def test_incremental_suggestions_uses_newer_custom_no_suggestions_result( "diffs": [{"new_path": "a.py", "old_path": "a.py", "diff": "@@ ... @@", "new_file": False, "deleted_file": False, "renamed_file": False}], } - gitlab_provider.mr.changes.return_value = {"changes": [{"new_path": "a.py"}]} + gitlab_provider.gl.http_list.return_value = [{"new_path": "a.py"}] gitlab_provider.get_incremental_commits(IncrementalPR(True), kind="suggestions") @@ -1929,7 +2130,6 @@ def test_persistent_state_ownership_uses_authenticated_user_id(self): @pytest.mark.parametrize("capability", [ "create_inline_comment", "publish_inline_comments", - "publish_file_comments", ]) def test_unimplemented_capabilities_stay_unsupported(self, capability): assert self._provider().is_supported(capability) is False @@ -1949,7 +2149,16 @@ def _provider(self, changes): provider = GitLabProvider.__new__(GitLabProvider) provider.mr = MagicMock() provider.mr.diffs.list.return_value = list(self.VERSIONS) - provider.mr.changes.return_value = {"changes": changes} + provider.mr.diff_refs = {"base_sha": "base", "head_sha": "head", "start_sha": "base"} + provider.gl = MagicMock() + provider.gl.http_list.return_value = changes + provider.gl.http_get.side_effect = lambda *_args, **_kwargs: { + "sha": "current-mr", + "diff_refs": dict(provider.mr.diff_refs), + "changes_count": str(len(changes)), + } + provider.id_project = "group/repo" + provider.id_mr = 1 provider.last_diff = "latest-at-construction" return provider diff --git a/tests/unittest/test_gitlab_provider_exception_narrowing.py b/tests/unittest/test_gitlab_provider_exception_narrowing.py new file mode 100644 index 0000000000..8cc90a99fb --- /dev/null +++ b/tests/unittest/test_gitlab_provider_exception_narrowing.py @@ -0,0 +1,141 @@ +"""Pin the exception contract of the GitLab provider: handle the expected, surface the rest. + +Read each test as one method's contract. While these handlers caught bare `Exception`, a +`TypeError` from our own code was indistinguishable from a GitLab outage: it was logged as an +API failure and the run continued with a wrong result. Keep the API or transport error +swallowed the way callers rely on, and let a programming error propagate. + +Read the last group as the fallback chain the issue calls the model case: `_project_by_path` +tries three routes, so its terminal warning has to say why all three came up empty. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from gitlab import GitlabError +from requests.exceptions import RequestException + +from pr_agent.git_providers.gitlab_provider import GitLabProvider + +API_ERRORS = [ + pytest.param(GitlabError("500 internal error"), id="gitlab-api-error"), + pytest.param(RequestException("connection reset"), id="transport-error"), +] + +# A bug in our own code, not a failure of the remote side. +UNEXPECTED_ERRORS = [ + pytest.param(TypeError("unhashable type"), id="TypeError"), + pytest.param(ValueError("invalid literal"), id="ValueError"), +] + + +def _make_provider(): + provider = GitLabProvider.__new__(GitLabProvider) + provider.gl = MagicMock() + provider.id_project = "group/repo" + provider.id_mr = 7 + provider.mr = MagicMock() + provider.temp_comments = [] + return provider + + +@pytest.mark.parametrize("error", API_ERRORS) +def test_add_reaction_returns_none_on_api_failure(error): + provider = _make_provider() + provider.gl.projects.get.side_effect = error + assert provider.add_reaction(11, "eyes") is None + + +@pytest.mark.parametrize("error", UNEXPECTED_ERRORS) +def test_add_reaction_propagates_unexpected_errors(error): + provider = _make_provider() + provider.gl.projects.get.side_effect = error + with pytest.raises(type(error)): + provider.add_reaction(11, "eyes") + + +@pytest.mark.parametrize("error", API_ERRORS) +def test_get_pr_labels_falls_back_to_the_cached_mr_on_api_failure(error): + provider = _make_provider() + provider.mr.labels = ["cached"] + provider._get_merge_request = MagicMock(side_effect=error) + assert provider.get_pr_labels(update=True) == ["cached"] + + +@pytest.mark.parametrize("error", UNEXPECTED_ERRORS) +def test_get_pr_labels_propagates_unexpected_errors(error): + provider = _make_provider() + provider.mr.labels = ["cached"] + provider._get_merge_request = MagicMock(side_effect=error) + with pytest.raises(type(error)): + provider.get_pr_labels(update=True) + + +@pytest.mark.parametrize("error", API_ERRORS) +def test_resolve_comment_thread_soft_fails_on_api_failure(error): + provider = _make_provider() + provider.mr.discussions.list.side_effect = error + provider.resolve_comment_thread(SimpleNamespace(id=1)) # must not raise + + +@pytest.mark.parametrize("error", UNEXPECTED_ERRORS) +def test_resolve_comment_thread_propagates_unexpected_errors(error): + provider = _make_provider() + provider.mr.discussions.list.side_effect = error + with pytest.raises(type(error)): + provider.resolve_comment_thread(SimpleNamespace(id=1)) + + +# --------------------------------------------------------------------------- +# _project_by_path: three routes, one reason +# --------------------------------------------------------------------------- + +def _capture_warnings(monkeypatch): + warnings = [] + logger = MagicMock() + logger.warning.side_effect = lambda message, *a, **k: warnings.append(message) + monkeypatch.setattr("pr_agent.git_providers.gitlab_provider.get_logger", lambda: logger) + return warnings + + +def test_project_by_path_reports_why_every_route_failed(monkeypatch): + warnings = _capture_warnings(monkeypatch) + provider = _make_provider() + provider.gl.projects.get.side_effect = GitlabError("404 Project Not Found") + provider.gl.projects.list.side_effect = RequestException("connection reset") + + assert provider._project_by_path("group/sub") is None + + assert len(warnings) == 1 + message = warnings[0] + assert "group/sub" in message + # All three routes are named, so the reader can tell a missing project from a dead network. + assert "encoded path: 404 Project Not Found" in message + assert "raw path: 404 Project Not Found" in message + assert "search: connection reset" in message + + +def test_project_by_path_reports_a_search_that_matched_the_wrong_project(monkeypatch): + warnings = _capture_warnings(monkeypatch) + provider = _make_provider() + provider.gl.projects.get.side_effect = GitlabError("404 Project Not Found") + provider.gl.projects.list.return_value = [SimpleNamespace(path_with_namespace="other/sub", id=3)] + + assert provider._project_by_path("group/sub") is None + assert "search matched 1 project(s), none with this exact path" in warnings[0] + + +def test_project_by_path_returns_the_project_when_a_route_works(): + provider = _make_provider() + project = SimpleNamespace(path_with_namespace="group/sub") + provider.gl.projects.get.return_value = project + + assert provider._project_by_path("group/sub") is project + + +def test_project_by_path_propagates_unexpected_errors(): + provider = _make_provider() + provider.gl.projects.get.side_effect = TypeError("bad argument") + with pytest.raises(TypeError): + provider._project_by_path("group/sub") diff --git a/tests/unittest/test_gitlab_provider_mr_url.py b/tests/unittest/test_gitlab_provider_mr_url.py index 4e76b6e147..f980be1675 100644 --- a/tests/unittest/test_gitlab_provider_mr_url.py +++ b/tests/unittest/test_gitlab_provider_mr_url.py @@ -1,5 +1,7 @@ from types import SimpleNamespace +import pytest + from pr_agent.git_providers.gitlab_provider import GitLabProvider @@ -128,3 +130,46 @@ def test_get_canonical_url_parts_keeps_standard_project_path(): "https://gitlab.example.com/group/project/-/blob/main", "?ref_type=heads", ) + + +@pytest.mark.parametrize("branch, expected", [ + # A '#' would otherwise start the URL fragment and swallow the file path and line anchor. + ("project#456", "project%23456"), + # A slash is a legal path separator in a branch name and must survive unencoded. + ("feature/cache", "feature/cache"), + ("feat/a#b", "feat/a%23b"), +]) +def test_get_canonical_url_parts_encodes_branch_names(branch, expected): + provider = _provider() + provider.pr_url = "https://gitlab.example.com/group/project/-/merge_requests/5" + provider.id_project = "group/project" + provider.gl = SimpleNamespace( + url="https://gitlab.example.com", + projects=SimpleNamespace(get=lambda _: SimpleNamespace(default_branch=branch)), + ) + provider.mr = SimpleNamespace( + web_url="https://gitlab.example.com/group/project/-/merge_requests/5" + ) + + assert provider.get_canonical_url_parts(repo_git_url=None, desired_branch=None) == ( + f"https://gitlab.example.com/group/project/-/blob/{expected}", + "?ref_type=heads", + ) + + +@pytest.mark.parametrize("branch, expected", [ + ("project#456", "project%23456"), + ("feature/cache", "feature/cache"), +]) +def test_get_line_link_encodes_source_branch(branch, expected): + provider = _provider() + provider.id_project = "group/project" + provider.gl = SimpleNamespace(url="https://gitlab.example.com") + provider.mr = SimpleNamespace( + web_url="https://gitlab.example.com/group/project/-/merge_requests/5", + source_branch=branch, + ) + + assert provider.get_line_link("src/app.py", 42) == ( + f"https://gitlab.example.com/group/project/-/blob/{expected}/src/app.py?ref_type=heads#L42" + ) diff --git a/tests/unittest/test_gitlab_provider_repo_settings.py b/tests/unittest/test_gitlab_provider_repo_settings.py new file mode 100644 index 0000000000..1fbb6b4fe2 --- /dev/null +++ b/tests/unittest/test_gitlab_provider_repo_settings.py @@ -0,0 +1,205 @@ +from unittest.mock import MagicMock, patch + +import pytest +from gitlab.exceptions import GitlabGetError + +from pr_agent.git_providers.gitlab_provider import GitLabProvider + + +def _file(content: bytes): + project_file = MagicMock() + project_file.decode.return_value = content + return project_file + + +def _provider_with_project(project): + provider = GitLabProvider.__new__(GitLabProvider) + provider.gl = MagicMock() + provider.gl.projects.get.return_value = project + provider.id_project = "group/repo" + return provider + + +@pytest.fixture +def project(): + project = MagicMock() + project.default_branch = "main" + return project + + +@pytest.fixture(autouse=True) +def no_global_settings(): + with patch.object(GitLabProvider, "_get_global_repo_settings", return_value=""): + yield + + +def _settings(config_branch): + settings = MagicMock() + settings.get.side_effect = lambda key, default=None: ( + config_branch if key == "CONFIG.CONFIG_BRANCH" else default + ) + return settings + + +def test_get_repo_settings_reads_default_branch_without_config_branch(project): + project.files.get.return_value = _file(b"[config]\nmodel='default'") + provider = _provider_with_project(project) + + with patch("pr_agent.git_providers.git_provider.get_settings", return_value=_settings(None)), \ + patch.dict("os.environ", {}, clear=True): + settings = provider.get_repo_settings() + + assert settings == [("local", b"[config]\nmodel='default'")] + project.files.get.assert_called_once_with(file_path=".pr_agent.toml", ref="main") + assert provider._resolved_config_branch == "main" + + +def test_get_repo_settings_uses_config_branch_from_settings(project): + project.files.get.return_value = _file(b"[config]\nmodel='x'") + provider = _provider_with_project(project) + + with patch("pr_agent.git_providers.git_provider.get_settings", return_value=_settings("feature-config")): + settings = provider.get_repo_settings() + + assert settings == [("local", b"[config]\nmodel='x'")] + project.files.get.assert_called_once_with(file_path=".pr_agent.toml", ref="feature-config") + assert provider._resolved_config_branch == "feature-config" + + +def test_get_repo_settings_uses_env_var_when_settings_are_missing(project): + project.files.get.return_value = _file(b"[config]\nmodel='env'") + provider = _provider_with_project(project) + + with patch("pr_agent.git_providers.git_provider.get_settings", return_value=_settings(" ")), \ + patch.dict("os.environ", {"PR_AGENT_CONFIG_BRANCH": "env-branch"}, clear=False): + settings = provider.get_repo_settings() + + assert settings == [("local", b"[config]\nmodel='env'")] + project.files.get.assert_called_once_with(file_path=".pr_agent.toml", ref="env-branch") + + +def test_get_repo_settings_falls_back_to_default_branch_when_config_branch_has_no_file(project): + project.files.get.side_effect = [ + GitlabGetError("404 Not Found", response_code=404), + _file(b"[config]\nmodel='default'"), + ] + provider = _provider_with_project(project) + + with patch("pr_agent.git_providers.git_provider.get_settings", return_value=_settings("feature-config")): + settings = provider.get_repo_settings() + + assert settings == [("local", b"[config]\nmodel='default'")] + assert [c.kwargs["ref"] for c in project.files.get.call_args_list] == ["feature-config", "main"] + assert provider._resolved_config_branch == "main" + + +def test_get_repo_settings_does_not_fall_back_on_non_404_error_for_config_branch(project): + project.files.get.side_effect = GitlabGetError("403 Forbidden", response_code=403) + provider = _provider_with_project(project) + + with patch("pr_agent.git_providers.git_provider.get_settings", return_value=_settings("feature-config")), \ + patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_logger: + settings = provider.get_repo_settings() + + assert settings == "" + project.files.get.assert_called_once_with(file_path=".pr_agent.toml", ref="feature-config") + mock_logger.return_value.warning.assert_called_once() + + +def test_get_repo_settings_missing_file_on_default_branch_is_quiet(project): + project.files.get.side_effect = GitlabGetError("404 Not Found", response_code=404) + provider = _provider_with_project(project) + + with patch("pr_agent.git_providers.git_provider.get_settings", return_value=_settings(None)), \ + patch.dict("os.environ", {}, clear=True), \ + patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_logger: + settings = provider.get_repo_settings() + + assert settings == "" + mock_logger.return_value.warning.assert_not_called() + + +def test_get_repo_settings_tree_follows_resolved_config_branch(project): + project.repository_tree.return_value = [ + {"type": "blob", "path": "sub/.pr_agent.toml"}, + {"type": "blob", "path": "README.md"}, + ] + provider = _provider_with_project(project) + provider._resolved_config_branch = "feature-config" + settings = MagicMock() + settings.config.per_directory_settings_max_tree_pages = 2 + + with patch("pr_agent.git_providers.gitlab_provider.get_settings", return_value=settings): + paths, resolved_ref = provider.get_repo_settings_tree("ignored-when-root-resolved") + + assert (paths, resolved_ref) == (["sub/.pr_agent.toml"], "feature-config") + project.repository_tree.assert_called_once_with(ref="feature-config", recursive=True, page=1, per_page=100) + + +def test_get_repo_settings_tree_defaults_to_default_branch(project): + project.repository_tree.return_value = [] + provider = _provider_with_project(project) + settings = MagicMock() + settings.config.per_directory_settings_max_tree_pages = 2 + + with patch("pr_agent.git_providers.gitlab_provider.get_settings", return_value=settings): + paths, resolved_ref = provider.get_repo_settings_tree() + + assert (paths, resolved_ref) == ([], "main") + project.repository_tree.assert_called_once_with(ref="main", recursive=True, page=1, per_page=100) + + +def test_get_repo_settings_tree_retries_default_branch_when_explicit_ref_is_missing(project): + project.repository_tree.side_effect = [ + GitlabGetError("404 Tree Not Found", response_code=404), + [{"type": "blob", "path": "sub/.pr_agent.toml"}], + ] + provider = _provider_with_project(project) + settings = MagicMock() + settings.config.per_directory_settings_max_tree_pages = 2 + + with patch("pr_agent.git_providers.gitlab_provider.get_settings", return_value=settings): + paths, resolved_ref = provider.get_repo_settings_tree("deleted-branch") + + assert (paths, resolved_ref) == (["sub/.pr_agent.toml"], "main") + assert [c.kwargs["ref"] for c in project.repository_tree.call_args_list] == ["deleted-branch", "main"] + + +def test_get_repo_settings_tree_returns_empty_when_default_branch_tree_is_missing_too(project): + project.repository_tree.side_effect = GitlabGetError("404 Tree Not Found", response_code=404) + provider = _provider_with_project(project) + settings = MagicMock() + settings.config.per_directory_settings_max_tree_pages = 2 + + with patch("pr_agent.git_providers.gitlab_provider.get_settings", return_value=settings): + paths, resolved_ref = provider.get_repo_settings_tree("deleted-branch") + + assert (paths, resolved_ref) == ([], "") + assert [c.kwargs["ref"] for c in project.repository_tree.call_args_list] == ["deleted-branch", "main"] + + +def test_get_repo_settings_tree_does_not_retry_on_non_404_error(project): + project.repository_tree.side_effect = GitlabGetError("403 Forbidden", response_code=403) + provider = _provider_with_project(project) + settings = MagicMock() + settings.config.per_directory_settings_max_tree_pages = 2 + + with patch("pr_agent.git_providers.gitlab_provider.get_settings", return_value=settings), \ + pytest.raises(GitlabGetError): + provider.get_repo_settings_tree("feature-config") + + project.repository_tree.assert_called_once() + + +def test_get_repo_settings_tree_skips_nested_settings_when_resolved_root_branch_vanished(project): + project.repository_tree.side_effect = GitlabGetError("404 Tree Not Found", response_code=404) + provider = _provider_with_project(project) + provider._resolved_config_branch = "feature-config" + settings = MagicMock() + settings.config.per_directory_settings_max_tree_pages = 2 + + with patch("pr_agent.git_providers.gitlab_provider.get_settings", return_value=settings): + paths, resolved_ref = provider.get_repo_settings_tree("feature-config") + + assert (paths, resolved_ref) == ([], "") + project.repository_tree.assert_called_once_with(ref="feature-config", recursive=True, page=1, per_page=100) diff --git a/tests/unittest/test_gitlab_ticket_requests.py b/tests/unittest/test_gitlab_ticket_requests.py new file mode 100644 index 0000000000..37389916f1 --- /dev/null +++ b/tests/unittest/test_gitlab_ticket_requests.py @@ -0,0 +1,125 @@ +"""Exercise ticket extraction through python-gitlab's real HTTP request construction.""" +import asyncio +import json +from types import SimpleNamespace +from urllib.parse import urlparse + +import gitlab +import pytest +import requests +from requests.adapters import BaseAdapter + +from pr_agent.tools import ticket_pr_compliance_check as tickets + + +class IssueTransport(BaseAdapter): + def __init__(self, outcomes=()): + super().__init__() + self.requests = [] + self.outcomes = iter(outcomes) + + def send(self, request, **kwargs): + self.requests.append(request) + outcome = next(self.outcomes, 200) + if isinstance(outcome, BaseException): + raise outcome + # A project metadata request must never reach the transport. + assert request.method == "GET" and "/issues/" in request.url + iid = int(urlparse(request.url).path.rsplit("/", 1)[-1]) + payload = { + "id": iid + 100, + "iid": iid, + "web_url": f"https://gitlab.example/group/repo/-/issues/{iid}", + "title": f"Issue {iid}", + "description": "x" * (tickets.MAX_TICKET_CHARACTERS + 1), + "labels": ["bug", "backend"], + } if outcome == 200 else {"message": "unavailable"} + response = requests.Response() + response.status_code = outcome + response._content = json.dumps(payload).encode() + response.headers["Content-Type"] = "application/json" + response.request = request + return response + + def close(self): + pass + + +@pytest.fixture +def provider_factory(monkeypatch): + # Keep unrelated integrations deterministic; retain the native extraction path. + monkeypatch.setattr(tickets, "add_jira_tickets", lambda provider, content: content) + sessions = [] + + def make(description, outcomes=(), token="offline-token", retry=False): + transport = IssueTransport(outcomes) + session = requests.Session() + session.mount("https://", transport) + sessions.append(session) + client = gitlab.Gitlab("https://gitlab.example/gitlab", private_token=token, session=session, + retry_transient_errors=retry) + provider = SimpleNamespace( + gl=client, id_project="group/repo", gitlab_url="https://gitlab.example/gitlab", + get_user_description=lambda: description, + supports_issue_url_tickets=lambda: False, + supports_issue_reference_tickets=lambda: True, + ) + return provider, transport + + yield make + for session in sessions: + session.close() + + +async def test_extract_issues_without_project_metadata(provider_factory): + provider, transport = provider_factory("Fixes #1 #2 other/sub/project#3 #4 #1") + result = await tickets.extract_tickets(provider) + + assert [ticket["ticket_id"] for ticket in result] == [1, 2, 3] + assert [urlparse(request.url).path for request in transport.requests] == [ + "/gitlab/api/v4/projects/group%2Frepo/issues/1", + "/gitlab/api/v4/projects/group%2Frepo/issues/2", + "/gitlab/api/v4/projects/other%2Fsub%2Fproject/issues/3", + ] + assert all(request.headers["PRIVATE-TOKEN"] == "offline-token" for request in transport.requests) + assert all(ticket["body"] == "x" * tickets.MAX_TICKET_CHARACTERS + "..." for ticket in result) + assert all(ticket["labels"] == "bug, backend" for ticket in result) + + +@pytest.mark.parametrize("failure", [401, 403, 404, 500, requests.Timeout("offline timeout")]) +async def test_issue_failures_preserve_successful_context(provider_factory, failure): + provider, transport = provider_factory("Fixes #1 #2", [failure, 200]) + result = await tickets.extract_tickets(provider) + + assert [ticket["ticket_id"] for ticket in result] == [2] + assert len(transport.requests) == 2 + + +async def test_failed_issue_is_fetched_again_on_next_extraction(provider_factory): + provider, transport = provider_factory("Fixes #1", [403, 200]) + assert await tickets.extract_tickets(provider) == [] + assert [ticket["ticket_id"] for ticket in await tickets.extract_tickets(provider)] == [1] + assert len(transport.requests) == 2 + + +async def test_gitlab_transport_retry_remains_enabled(provider_factory, monkeypatch): + monkeypatch.setattr("gitlab.utils.time.sleep", lambda seconds: None) + provider, transport = provider_factory("Fixes #1", [503, 200], retry=True) + assert [ticket["ticket_id"] for ticket in await tickets.extract_tickets(provider)] == [1] + assert len(transport.requests) == 2 + + +async def test_cancellation_propagates_without_fetching_later_issues(provider_factory): + provider, transport = provider_factory("Fixes #1 #2", [asyncio.CancelledError()]) + with pytest.raises(asyncio.CancelledError): + await tickets.extract_tickets(provider) + assert len(transport.requests) == 1 + + +async def test_separate_credentials_do_not_share_results(provider_factory): + allowed, allowed_transport = provider_factory("Fixes #1", token="allowed-offline-token") + denied, denied_transport = provider_factory("Fixes #1", [403], token="denied-offline-token") + assert len(await tickets.extract_tickets(allowed)) == 1 + assert await tickets.extract_tickets(denied) == [] + assert allowed_transport.requests[0].headers["PRIVATE-TOKEN"] == "allowed-offline-token" + assert denied_transport.requests[0].headers["PRIVATE-TOKEN"] == "denied-offline-token" diff --git a/tests/unittest/test_gunicorn_config.py b/tests/unittest/test_gunicorn_config.py index 153b658a6a..acb32efd08 100644 --- a/tests/unittest/test_gunicorn_config.py +++ b/tests/unittest/test_gunicorn_config.py @@ -1,3 +1,7 @@ +import sys +from types import ModuleType, SimpleNamespace +from unittest.mock import Mock + import pytest from pr_agent.servers import gunicorn_config @@ -174,3 +178,38 @@ def test_when_ready_freezes_gc(monkeypatch): monkeypatch.setattr(gunicorn_config.gc, "freeze", lambda: calls.append(True)) gunicorn_config.when_ready(server=None) assert calls == [True] + + +def _fake_prometheus_client(monkeypatch): + """Stub prometheus_client so the gate tests never import the real module. + + Importing the real module would let it read PROMETHEUS_MULTIPROC_DIR and + permanently decide the process-wide storage mode, which collides with the + telemetry suite's import-time decision. A fake module keeps the test + isolated and still exercises child_exit's own import plus the gate. + """ + multiproc_module = ModuleType("prometheus_client.multiprocess") + multiproc_module.mark_process_dead = Mock() + client_module = ModuleType("prometheus_client") + client_module.multiprocess = multiproc_module + monkeypatch.setitem(sys.modules, "prometheus_client", client_module) + monkeypatch.setitem(sys.modules, "prometheus_client.multiprocess", multiproc_module) + return multiproc_module + + +def test_child_exit_skips_prometheus_without_multiproc_dir(monkeypatch): + fake_multiprocess = _fake_prometheus_client(monkeypatch) + monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR", raising=False) + + gunicorn_config.child_exit(server=None, worker=SimpleNamespace(pid=123)) + + fake_multiprocess.mark_process_dead.assert_not_called() + + +def test_child_exit_marks_worker_dead_when_multiproc_dir_set(monkeypatch, tmp_path): + fake_multiprocess = _fake_prometheus_client(monkeypatch) + monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", str(tmp_path)) + + gunicorn_config.child_exit(server=None, worker=SimpleNamespace(pid=456)) + + fake_multiprocess.mark_process_dead.assert_called_once_with(456) diff --git a/tests/unittest/test_hidden_comment_markers.py b/tests/unittest/test_hidden_comment_markers.py new file mode 100644 index 0000000000..9258e980ee --- /dev/null +++ b/tests/unittest/test_hidden_comment_markers.py @@ -0,0 +1,107 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from pr_agent.algo.comment_identity import ( + PRCodeSuggestionsIdentity, + PRReviewIdentity, + add_comment_identity, + comment_carries_other_identity, + comment_matches_identity, + hidden_marker_forms, + render_hidden_marker, +) +from pr_agent.git_providers.bitbucket_provider import BitbucketProvider +from pr_agent.git_providers.git_provider import GitProvider +from pr_agent.tools.pr_reviewer import PRReviewer + + +@pytest.mark.parametrize("identity", list(PRReviewIdentity) + list(PRCodeSuggestionsIdentity)) +def test_marker_forms_are_interchangeable_but_bounded(identity): + html = identity.value + reference = f"[{html[5:-4]}]: https://github.com/The-PR-Agent/pr-agent" + assert hidden_marker_forms(html) == (html, reference) + provider = BitbucketProvider.__new__(BitbucketProvider) + assert render_hidden_marker(html, provider) == reference + assert render_hidden_marker(reference) == html + for stored in (html, reference): + body = f"## Review\n\n{stored}\n\nbody" + for query in (html, reference): + assert comment_matches_identity(body, query) + assert not comment_matches_identity(f"## Review\n\n> {stored}", query) + assert not comment_matches_identity(f"a\nb\nc\nd\ne\n{stored}", query) + assert not comment_carries_other_identity(body, query) + assert add_comment_identity(body, query, provider) == body + assert add_comment_identity("## Review\n\nbody", html, provider) == f"## Review\n\n{reference}\n\nbody" + + +@pytest.mark.parametrize("stored", hidden_marker_forms(PRReviewIdentity.REGULAR.value)) +def test_bitbucket_rerun_updates_both_marker_forms_in_place(stored): + provider = BitbucketProvider.__new__(BitbucketProvider) + comment = SimpleNamespace(body=f"## Review\n\n{stored}\n\nold") + provider.get_issue_comments = MagicMock(return_value=[comment]) + provider.get_issue_comments_newest_first = MagicMock(return_value=[comment]) + provider.edit_comment = MagicMock(return_value=True) + provider.publish_comment = MagicMock() + provider.get_latest_commit_url = MagicMock(return_value="https://example.com/commit/123") + provider.get_comment_url = MagicMock(return_value="https://example.com/comment/1") + result = GitProvider.publish_persistent_comment_full( + provider, "## Review\n\nnew", initial_header="## Review", + identity_marker=PRReviewIdentity.REGULAR.value, final_update_message=False, + ) + assert result is comment + provider.publish_comment.assert_not_called() + body = provider.edit_comment.call_args.args[1] + assert "" out = d.build_markers("aaaaaaaaaaaa", "bbbbbbbbbbbb") @@ -76,6 +97,29 @@ def test_build_markers(): assert "" in out +def test_build_markers_uses_bitbucket_hidden_form(): + provider = MagicMock() + provider.supports_html_comment_markers.return_value = False + + out = d.build_markers("aaaaaaaaaaaa", "bbbbbbbbbbbb", provider) + + assert " B", "pr_files": [{ + "filename": "src/app.py", "changes_summary": "- Handle failures", + "changes_title": "Handle runtime failures", "label": "bug fix", + }]}), + (PRDescriptionHeaders, {"type": ["Tests"], "description": "Cover the new behavior.", + "title": "Describe the test changes", "changes_diagram": ""}), + (FileDescription, {"filename": "src/app.py", "changes_summary": "- Handle failures", + "changes_title": "Handle runtime failures", "label": "bug fix"}), + (PRFilesWalkthrough, {"pr_files": [{ + "filename": "src/app.py", "changes_summary": "- Handle failures", + "changes_title": "Handle runtime failures", "label": "bug fix", + }]}), + (Labels, {"labels": ["Bug fix", "Tests"]}), + (CodeDocumentation, {"Code Documentation": [{ + "relevant file": "src/app.py", "relevant line": 12, "doc placement": "after", + "documentation": "Document the handler.", + }]}), + (PRRankResponses, { + "which_response_was_better": 1, + "why": "It is clearer.", + "score_response1": 9, + "score_response2": 7, + }), + (DocHelper, {"user_question": "How?", "response": "Use the helper.", "relevant_sections": [{ + "file_name": "docs/guide.md", "relevant_section_header_string": "## Usage", + }], "question_is_relevant": 1}), + (DocHeadingsHelper, { + "user_question": "How?", + "relevant_files_ranking": [{"idx": 0, "file_name": "docs/guide.md"}], + }), + ], +) +def test_output_models_validate_complete_fixtures(model, payload): + model.model_validate(payload) + + +@pytest.mark.parametrize( + ("payload", "expected"), + [ + ({"labels": [" Bug fix ", "Tests"]}, ["Bug fix", "Tests"]), + ({"labels": "Bug fix, Tests"}, ["Bug fix", "Tests"]), + ( + {"labels": [{"name": "Bug fix"}, {"label": "Tests"}, {"title": "Docs"}, + {"value": "Perf"}, 1, 2.5, {}, False]}, + ["Bug fix", "Tests", "Docs", "Perf", "1", "2.5"], + ), + ({"labels": [{"name": " ", "label": "Tests"}, {}, "Bug fix"]}, ["Tests", "Bug fix"]), + ({"labels": []}, []), + ], +) +def test_labels_normalize_tolerated_model_shapes(payload, expected): + assert Labels.model_validate(payload).labels == expected + + +@pytest.mark.parametrize( + "payload", + [ + {"labels": ""}, + {"labels": " "}, + {"labels": " , "}, + {"labels": [{}, False]}, + {"labels": 7}, + {"types": ["Bug fix"]}, + [], + ], +) +def test_labels_reject_unusable_model_shapes(payload): + with pytest.raises(ValueError): + Labels.model_validate(payload) + + +def test_review_alias_accepts_prompt_field_name(): + assert Review.model_validate({ + "key_issues_to_review": [], + "estimated_effort_to_review_[1-5]": 3, + }).estimated_effort_to_review == 3 + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "estimated_effort_to_review": 3}) + + +def test_review_rejects_coercible_numeric_types_and_strips_prompt_literals(): + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "score": "89"}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "score": True}) + + review = Review.model_validate({ + "key_issues_to_review": [], + "risk_level": "low\n", + "merge_recommendation": "safe_to_merge\n", + "relevant_tests": "No\n", + }) + assert review.risk_level == "low" + assert review.merge_recommendation == "safe_to_merge" + assert review.relevant_tests == "No" + + +def test_required_label_and_list_constraints_are_enforced(): + suggestion = { + "relevant_file": "src/app.py", "language": "python", "existing_code": "return value", + "suggestion_content": "Handle the missing value.", "improved_code": "return value or default", + "one_sentence_summary": "Handle missing values", + } + with pytest.raises(ValueError): + CodeSuggestion.model_validate(suggestion) + with pytest.raises(ValueError): + PRRankResponses.model_validate({ + "which_response_was_better": 3, "why": "No", "score_response1": 1, "score_response2": 1, + }) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "can_be_split": [{"relevant_files": [], "title": "x"}] * 4}) + with pytest.raises(ValueError): + PRDescription.model_validate({"type": ["Tests"], "title": "x", "pr_files": [{ + "filename": "x", "changes_title": "x", "label": "x", + }] * 21}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "estimated_effort_to_review_[1-5]": 0}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "score": 101}) + with pytest.raises(ValueError): + PRCodeSuggestionsFeedback.model_validate({"code_suggestions": [{ + "suggestion_summary": "x", "relevant_file": "x", "relevant_lines_start": 1, + "relevant_lines_end": 1, "suggestion_score": 11, "why": "x", + }]}) + with pytest.raises(ValueError): + PRRankResponses.model_validate({ + "which_response_was_better": 1, "why": "x", "score_response1": 0, "score_response2": 11, + }) + with pytest.raises(ValueError): + DocHelper.model_validate({ + "user_question": "x", "response": "x", "relevant_sections": [], "question_is_relevant": 2, + }) + with pytest.raises(ValueError): + DocHeadingsHelper.model_validate({ + "user_question": "x", "relevant_files_ranking": [{"idx": -1, "file_name": "x"}], + }) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "risk_level": "critical"}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "merge_recommendation": "approve"}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [], "relevant_tests": "Maybe"}) + with pytest.raises(ValueError): + PRDescription.model_validate({"type": [], "title": "x"}) + with pytest.raises(ValueError): + PRDescriptionHeaders.model_validate({"type": [], "title": "x"}) + with pytest.raises(ValueError): + Review.model_validate({"key_issues_to_review": [{ + "relevant_file": "x", "issue_header": "x", "issue_content": "x", + "start_line": 1, "end_line": 1, "unexpected": "x", + }]}) + + +def _split_type_args(value): + parts, depth, start = [], 0, 0 + for index, character in enumerate(value): + if character == "[": + depth += 1 + elif character == "]": + depth -= 1 + elif character == "," and depth == 0: + parts.append(value[start:index]) + start = index + 1 + parts.append(value[start:]) + return parts + + +def _prompt_type_signature(annotation): + annotation = annotation.strip().replace(" ", "") + if annotation.startswith("Optional["): + annotation = annotation[9:-1] + if annotation.startswith("Union["): + return ("union", tuple(sorted( + (_prompt_type_signature(item) for item in _split_type_args(annotation[6:-1])), key=repr + ))) + if annotation.startswith("List["): + return ("list", _prompt_type_signature(annotation[5:-1])) + if annotation.startswith("Literal["): + values = tuple(value.strip().strip('"').strip("'") for value in annotation[8:-1].split(",")) + return ("literal", values) + if annotation == "Label": + return "str" + if annotation == "relevant_section": + return "RelevantSection" + if annotation == "file_idx_and_path": + return "FileIdxAndPath" + return annotation + + +def _model_type_signature(annotation): + origin = get_origin(annotation) + if str(origin) == "typing.Annotated": + return _model_type_signature(get_args(annotation)[0]) + if annotation is StrictInt: + return "int" + if str(origin) == "typing.Literal": + return ("literal", tuple(str(value) for value in get_args(annotation))) + if origin in (list,): + return ("list", _model_type_signature(get_args(annotation)[0])) + if origin in (UnionType,): + args = get_args(annotation) + elif str(origin) == "typing.Union": + args = get_args(annotation) + else: + return getattr(annotation, "__name__", str(annotation).split(".")[-1]) + members = tuple(sorted( + (_model_type_signature(item) for item in args if item is not type(None)), key=repr + )) + return members[0] if len(members) == 1 else ("union", members) + + +PROMPT_MODELS = { + "pr_reviewer_prompts.toml": {"SubPR": SubPR, "KeyIssuesComponentLink": KeyIssuesComponentLink, + "TodoSection": TodoSection, "TicketCompliance": TicketCompliance, + "ContributionTimeCostEstimate": ContributionTimeCostEstimate, "Review": Review, + "PRReview": PRReview}, + "pr_description_prompts.toml": {"FileDescription": FileDescription, "PRDescription": PRDescription}, + "pr_description_only_description_prompts.toml": {"PRDescriptionHeaders": PRDescriptionHeaders}, + "pr_description_only_files_prompts.toml": { + "FileDescription": FileDescription, "PRFilesWalkthrough": PRFilesWalkthrough, + }, + "pr_custom_labels.toml": {"Labels": Labels}, + "pr_evaluate_prompt_response.toml": {"PRRankResponses": PRRankResponses}, + "pr_help_prompts.toml": {"relevant_section": RelevantSection, "DocHelper": DocHelper}, + "pr_help_docs_prompts.toml": {"relevant_section": RelevantSection, "DocHelper": DocHelper}, + "pr_help_docs_headings_prompts.toml": {"file_idx_and_path": FileIdxAndPath, "DocHeadingsHelper": DocHeadingsHelper}, + "code_suggestions/pr_code_suggestions_prompts.toml": { + "CodeSuggestion": CodeSuggestion, "PRCodeSuggestions": PRCodeSuggestions, + }, + "code_suggestions/pr_code_suggestions_prompts_not_decoupled.toml": { + "CodeSuggestion": CodeSuggestion, "PRCodeSuggestions": PRCodeSuggestions, + }, + "code_suggestions/pr_code_suggestions_reflect_prompts.toml": { + "CodeSuggestionFeedback": CodeSuggestionFeedback, + "PRCodeSuggestionsFeedback": PRCodeSuggestionsFeedback, + }, +} + + +def test_prompt_fields_are_present_in_output_models(): + root = Path(__file__).parents[2] / "pr_agent" / "settings" + for relative_path, classes in PROMPT_MODELS.items(): + text = (root / relative_path).read_text(encoding="utf-8") + for class_name, model in classes.items(): + class_start = text.index(f"class {class_name}(BaseModel):") + block_start = class_start + len(f"class {class_name}(BaseModel):") + next_class = text.find("\nclass ", block_start) + separator = text.find("=====", block_start) + block_end = min(value for value in (next_class, separator) if value >= 0) + block = text[block_start:block_end] + declared = set(re.findall(r"^ ([A-Za-z_][A-Za-z0-9_\[\]-]*):", block, re.MULTILINE)) + model_fields = set(model.model_fields) + aliases = {field.alias for field in model.model_fields.values() if field.alias} + assert declared <= model_fields | aliases, f"{relative_path}: {class_name} has unmodelled fields" + for field_name, annotation in re.findall( + r"^ ([A-Za-z_][A-Za-z0-9_\[\]-]*):\s*([^=]+)", block, re.MULTILINE + ): + field = next(( + value for value in model.model_fields.values() + if value.alias == field_name or value.alias is None and value.validation_alias == field_name + ), None) + if field is None: + field = model.model_fields.get(field_name) + assert field is not None, f"{relative_path}: {class_name} field {field_name} is missing" + assert _prompt_type_signature(annotation) == _model_type_signature(field.annotation), ( + f"{relative_path}: {class_name}.{field_name} type drift" + ) + + +def test_prompt_enum_contracts_are_preserved(): + assert {member.value for member in PRType} == {"Bug fix", "Tests", "Enhancement", "Documentation", "Other"} + assert {member.value for member in Label} == {"Bug fix", "Tests", "Enhancement", "Documentation", "Other"} + + +def test_add_docs_prompt_matches_output_model(): + prompt = (Path(__file__).parents[2] / "pr_agent" / "settings" / "pr_add_docs.toml").read_text(encoding="utf-8") + assert "Code Documentation:" in prompt + assert "relevant file:" in prompt + assert "relevant line:" in prompt + assert "doc placement:" in prompt + assert "documentation:" in prompt + CodeDocumentation.model_validate({"Code Documentation": [{ + "relevant file": "src/app.py", "relevant line": 12, "doc placement": "after", + "documentation": "Document the handler.", + }]}) + + +def test_duplicate_doc_items_are_rejected(): + item = { + "relevant file": "src/app.py", "relevant line": 12, "doc placement": "after", + "documentation": "Document the handler.", + } + with pytest.raises(ValueError): + CodeDocumentation.model_validate({"Code Documentation": [item, item]}) + + +def test_estimate_effort_example_is_a_strict_integer(): + text = "estimated_effort_to_review_[1-5]: 3" + assert type(load_yaml(text)["estimated_effort_to_review_[1-5]"]) is int + Review.model_validate({ + "key_issues_to_review": [], + "estimated_effort_to_review_[1-5]": load_yaml(text)["estimated_effort_to_review_[1-5]"], + }) + + +def test_ranking_example_is_a_valid_numeric_payload(): + text = 'which_response_was_better: 1\nwhy: "It is clearer."\nscore_response1: 9\nscore_response2: 7' + parsed = load_yaml(text) + assert isinstance(parsed["which_response_was_better"], int) + PRRankResponses.model_validate(parsed) diff --git a/tests/unittest/test_per_directory_settings.py b/tests/unittest/test_per_directory_settings.py new file mode 100644 index 0000000000..45af5443a6 --- /dev/null +++ b/tests/unittest/test_per_directory_settings.py @@ -0,0 +1,1255 @@ +import copy +from types import SimpleNamespace +from unittest.mock import MagicMock, call + +import pytest +from github import GithubException +from starlette_context import context, request_cycle_context + +from pr_agent.config_loader import get_settings, global_settings +from pr_agent.git_providers import utils as git_utils +from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.git_providers.gitlab_provider import GitLabProvider + +ROOT_TOML = b""" +[pr_reviewer] +num_max_findings = 10 + +[config] +model = "root-model" +temperature = 0.1 +""" + +SERVICES_TOML = b""" +[pr_reviewer] +num_max_findings = 5 + +[ignore] +glob = ["*.gen.js"] +""" + +SERVICES_AUTH_TOML = b""" +[pr_reviewer] +num_max_findings = 3 + +[config] +temperature = 0.5 +model = "auth-model" +""" + +SERVICES_BILLING_TOML = b""" +[pr_reviewer] +num_max_findings = 7 +""" + + +class FakePerDirProvider: + def __init__(self, root_settings=None, tree_paths=(), contents=None, files=None, + resolved_ref="main", full_files=None): + self.root_settings = root_settings + self.tree_paths = tuple(tree_paths) + self.contents = dict(contents) if contents else {} + self.files = list(files) if files else [] + self.full_files = list(full_files) if full_files is not None else None + self.resolved_ref = resolved_ref + self.tree_calls = 0 + self.tree_refs = [] + self.contents_calls = [] + self.get_files_calls = 0 + self.pr_file_paths_calls = 0 + + def get_repo_settings(self): + return self.root_settings + + def get_files(self): + self.get_files_calls += 1 + return self.files + + def get_pr_file_paths(self): + self.pr_file_paths_calls += 1 + return self.full_files if self.full_files is not None else self.files + + def get_repo_settings_tree(self, ref): + self.tree_calls += 1 + self.tree_refs.append(ref) + return list(self.tree_paths), self.resolved_ref + + def get_repo_settings_contents(self, paths, ref): + self.contents_calls.append((list(paths), ref)) + return {path: self.contents[path] for path in paths if path in self.contents} + + def is_supported(self, capability): + return False + + def publish_comment(self, body): + pass + + def publish_persistent_comment(self, *args, **kwargs): + pass + + +@pytest.fixture +def fresh_global_settings(): + """Restore module-level global_settings after each test in case anything mutated it.""" + snapshot = copy.deepcopy(global_settings.as_dict()) + yield + for section in set(global_settings.as_dict().keys()) - set(snapshot.keys()): + global_settings.unset(section) + for section, contents in snapshot.items(): + global_settings.unset(section) + global_settings.set(section, copy.deepcopy(contents), merge=False) + + +@pytest.fixture +def per_dir_settings(fresh_global_settings): + """Request-scoped settings clone with the per-directory feature enabled.""" + with request_cycle_context({}): + context["settings"] = copy.deepcopy(global_settings) + settings = get_settings() + settings.config.enable_per_directory_settings = True + yield + + +def _provider(tree_paths, contents, files, root_settings=b"", full_files=None): + return FakePerDirProvider( + root_settings=root_settings, + tree_paths=tree_paths, + contents=contents, + files=files, + full_files=full_files, + ) + + +class TestResolvePerDirectorySettings: + def test_literal_backslashes_do_not_activate_nested_settings(self, per_dir_settings): + provider = _provider( + tree_paths=["services/auth/.pr_agent.toml"], + contents={"services/auth/.pr_agent.toml": SERVICES_AUTH_TOML}, + files=[r"services\auth\file.py"], + ) + + assert git_utils._get_per_directory_settings(provider) == [] + assert provider.contents_calls == [] + + def test_walks_up_from_changed_file_to_root(self, per_dir_settings): + provider = _provider( + tree_paths=[".pr_agent.toml", "services/.pr_agent.toml", "services/auth/.pr_agent.toml"], + contents={ + "services/.pr_agent.toml": SERVICES_TOML, + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + }, + files=["services/auth/api.py", "other/README.md"], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + # The root .pr_agent.toml is already applied through get_repo_settings() and + # must not be re-applied here; ancestor configs are returned shallowest-first + # so a nearer file overrides a farther one. + assert [path for path, _ in resolved] == [ + "services/.pr_agent.toml", + "services/auth/.pr_agent.toml", + ] + + def test_sibling_configs_both_applied(self, per_dir_settings): + provider = _provider( + tree_paths=["services/auth/.pr_agent.toml", "services/billing/.pr_agent.toml"], + contents={ + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + "services/billing/.pr_agent.toml": SERVICES_BILLING_TOML, + }, + files=["services/auth/api.py", "services/billing/x.py"], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + assert sorted(path for path, _ in resolved) == [ + "services/auth/.pr_agent.toml", + "services/billing/.pr_agent.toml", + ] + + def test_sibling_overlap_is_detected_and_warned(self): + from loguru import logger as loguru_logger + + ordered = ["services/auth", "services/billing"] + contents = { + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + "services/billing/.pr_agent.toml": SERVICES_BILLING_TOML, + } + + captured_lines = [] + sink_id = loguru_logger.add( + lambda msg: captured_lines.append(str(msg)), + level="WARNING", + ) + try: + conflicts = git_utils._warn_on_sibling_key_conflicts(ordered, contents) + finally: + loguru_logger.remove(sink_id) + + assert conflicts == [("pr_reviewer", "num_max_findings", "services/billing")] + assert any("pr_reviewer.num_max_findings" in line for line in captured_lines) + + def test_sibling_conflict_ignores_disjoint_keys(self): + ordered = ["services/auth", "services/billing"] + contents = { + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + "services/billing/.pr_agent.toml": b"[pr_description]\nuse_description_markers = true\n", + } + + conflicts = git_utils._warn_on_sibling_key_conflicts(ordered, contents) + + assert conflicts == [] + + def test_sibling_key_shared_by_two_of_three_is_warned(self): + from loguru import logger as loguru_logger + + ordered = ["services/auth", "services/billing", "services/orders"] + contents = { + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + "services/billing/.pr_agent.toml": SERVICES_BILLING_TOML, + "services/orders/.pr_agent.toml": b"[pr_description]\nuse_description_markers = true\n", + } + + captured_lines = [] + sink_id = loguru_logger.add( + lambda msg: captured_lines.append(str(msg)), + level="WARNING", + ) + try: + conflicts = git_utils._warn_on_sibling_key_conflicts(ordered, contents) + finally: + loguru_logger.remove(sink_id) + + # pr_reviewer.num_max_findings is set by auth + billing only; orders only sets config.temperature. + assert conflicts == [("pr_reviewer", "num_max_findings", "services/billing")] + assert any("pr_reviewer.num_max_findings" in line for line in captured_lines) + + def test_sibling_winner_is_last_owner_that_sets_key(self): + ordered = ["services/auth", "services/billing", "services/orders"] + contents = { + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + "services/billing/.pr_agent.toml": SERVICES_BILLING_TOML, + # orders is the last sibling in path order but its content is invalid TOML, + # so it can never be the winner for a key it does not set. + "services/orders/.pr_agent.toml": b"[pr_reviewer\nbroken = true\n", + } + + conflicts = git_utils._warn_on_sibling_key_conflicts(ordered, contents) + + assert conflicts == [("pr_reviewer", "num_max_findings", "services/billing")] + + def test_no_config_crossed_returns_empty(self, per_dir_settings): + provider = _provider( + tree_paths=["services/auth/.pr_agent.toml"], + contents={"services/auth/.pr_agent.toml": SERVICES_AUTH_TOML}, + files=["docs/readme.md"], + ) + + assert git_utils._get_per_directory_settings(provider) == [] + + def test_cap_keeps_shallowest_configs(self, per_dir_settings): + get_settings().config.per_directory_settings_max_files = 2 + provider = _provider( + tree_paths=[ + "svc1/.pr_agent.toml", + "svc1/deep/.pr_agent.toml", + "svc2/.pr_agent.toml", + ], + contents={ + "svc1/.pr_agent.toml": SERVICES_TOML, + "svc1/deep/.pr_agent.toml": SERVICES_AUTH_TOML, + "svc2/.pr_agent.toml": SERVICES_BILLING_TOML, + }, + files=["svc1/deep/api.py", "svc2/x.py"], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + # Shallowest first: the deeper svc1/deep config is dropped once the cap is full. + assert [path for path, _ in resolved] == [ + "svc1/.pr_agent.toml", + "svc2/.pr_agent.toml", + ] + + def test_cap_cutting_an_equal_depth_group_keeps_laterpath_winners(self, per_dir_settings): + get_settings().config.per_directory_settings_max_files = 2 + provider = _provider( + tree_paths=[ + "svc1/.pr_agent.toml", + "svc2/.pr_agent.toml", + "svc3/.pr_agent.toml", + ], + contents={ + "svc1/.pr_agent.toml": SERVICES_TOML, + "svc2/.pr_agent.toml": SERVICES_BILLING_TOML, + "svc3/.pr_agent.toml": SERVICES_AUTH_TOML, + }, + files=["svc1/x.py", "svc2/y.py", "svc3/z.py"], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + # The cap cuts through an equal-depth group: the lexicographically-later (winning) + # siblings are retained and svc3 still participates as the documented winner. + assert [path for path, _ in resolved] == [ + "svc2/.pr_agent.toml", + "svc3/.pr_agent.toml", + ] + + def test_per_directory_oversized_config_is_dropped(self, per_dir_settings, monkeypatch): + monkeypatch.setattr(git_utils, "MAX_TOML_SIZE_IN_BYTES", 100) + provider = _provider( + tree_paths=["svc1/.pr_agent.toml", "svc2/.pr_agent.toml"], + contents={ + "svc1/.pr_agent.toml": b"[config]\n" + b"#" * 200, + "svc2/.pr_agent.toml": SERVICES_TOML, + }, + files=["svc1/x.py", "svc2/y.py"], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + # An over-limit nested file is dropped before retention or parsing; the valid one stays. + assert [path for path, _ in resolved] == ["svc2/.pr_agent.toml"] + + def test_per_directory_aggregate_budget_stops_retention(self, per_dir_settings, monkeypatch): + monkeypatch.setattr( + git_utils, "_PER_DIRECTORY_SETTINGS_AGGREGATE_BYTES", len(SERVICES_TOML) + 10 + ) + provider = _provider( + tree_paths=[ + "svc1/.pr_agent.toml", + "svc2/.pr_agent.toml", + "svc3/.pr_agent.toml", + ], + contents={ + "svc1/.pr_agent.toml": SERVICES_TOML, + "svc2/.pr_agent.toml": SERVICES_BILLING_TOML, + "svc3/.pr_agent.toml": SERVICES_AUTH_TOML, + }, + files=["svc1/x.py", "svc2/y.py", "svc3/z.py"], + ) + + reserved = git_utils._get_per_directory_settings(provider) + + # Once the aggregate budget is exhausted, further files are not retained. + assert [path for path, _ in reserved] == ["svc1/.pr_agent.toml"] + + def test_sibling_conflict_parse_skips_oversized_files(self, per_dir_settings, monkeypatch): + monkeypatch.setattr(git_utils, "MAX_TOML_SIZE_IN_BYTES", 100) + oversized = b"[pr_reviewer]\nnum_max_findings = 10\n" + b"#" * 300 + contents = { + "svc1/.pr_agent.toml": oversized, + "svc2/.pr_agent.toml": b"[pr_reviewer]\nnum_max_findings = 5\n", + } + + conflicts = git_utils._warn_on_sibling_key_conflicts(["svc1", "svc2"], contents) + + # The oversized sibling is not parsed, so no conflict with svc2 can be reported. + assert conflicts == [] + + def test_disabled_returns_empty_without_any_provider_call(self, fresh_global_settings): + with request_cycle_context({}): + context["settings"] = copy.deepcopy(global_settings) + provider = _provider( + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": SERVICES_TOML}, + files=["services/api.py"], + ) + + assert git_utils._get_per_directory_settings(provider) == [] + assert provider.tree_calls == 0 + assert provider.get_files_calls == 0 + + def test_provider_without_support_is_inert(self, per_dir_settings): + class BareProvider: + def __init__(self): + self.get_files_calls = 0 + + def get_repo_settings(self): + return b"" + + def get_files(self): + self.get_files_calls += 1 + return ["services/api.py"] + + provider = BareProvider() + assert git_utils._get_per_directory_settings(provider) == [] + assert provider.get_files_calls == 0 + + def test_changed_file_path_normalization(self, per_dir_settings): + provider = _provider( + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": SERVICES_TOML}, + files=[ + "services/plain.py", + {"new_path": "services/dict_new.py"}, + {"filename": "services/dict_filename.py"}, + SimpleNamespace(filename="services/obj_filename.py"), + ], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + assert [path for path, _ in resolved] == ["services/.pr_agent.toml"] + + def test_changed_file_paths_include_rename_metadata(self, per_dir_settings): + provider = _provider( + tree_paths=["legacy/.pr_agent.toml", "services/.pr_agent.toml"], + contents={ + "legacy/.pr_agent.toml": SERVICES_TOML, + "services/.pr_agent.toml": SERVICES_TOML, + }, + files=[ + {"new_path": "services/api.py", "old_path": "legacy/api.py"}, + SimpleNamespace(new_path="services/web.py", previous_filename="legacy/web.py"), + {"new_path": "services/fresh.py", "old_path": None}, + ], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + # Both the source and the destination side of each move contribute paths, + # so their ancestor configs apply; a bare new file contributes only its own. + assert {path for path, _ in resolved} == { + "legacy/.pr_agent.toml", + "services/.pr_agent.toml", + } + + def test_incremental_state_does_not_shrink_discovery(self, per_dir_settings): + provider = _provider( + tree_paths=["services/.pr_agent.toml", "services/auth/.pr_agent.toml"], + contents={ + "services/.pr_agent.toml": SERVICES_TOML, + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + }, + files=["services/api.py"], + full_files=["services/api.py", "services/auth/other.py"], + ) + + resolved = git_utils._get_per_directory_settings(provider) + + # get_files() narrows to the unreviewed subset once an incremental review + # is active; discovery must use the complete PR file set instead, so a later + # command in the same request does not silently drop per-directory configs. + assert {path for path, _ in resolved} == { + "services/.pr_agent.toml", + "services/auth/.pr_agent.toml", + } + assert provider.get_files_calls == 0 + assert provider.pr_file_paths_calls == 1 + + def test_config_branch_passed_to_tree(self, per_dir_settings): + get_settings().set("CONFIG.CONFIG_BRANCH", "cfg-branch") + provider = _provider( + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": SERVICES_TOML}, + files=["services/api.py"], + ) + + git_utils._get_per_directory_settings(provider) + + assert provider.tree_refs == ["cfg-branch"] + + def test_get_files_failure_degrades_to_empty(self, per_dir_settings): + class ExplodingProvider(FakePerDirProvider): + def get_pr_file_paths(self): + raise RuntimeError("boom") + + provider = ExplodingProvider( + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": SERVICES_TOML}, + ) + + assert git_utils._get_per_directory_settings(provider) == [] + + def test_tree_failure_degrades_to_empty(self, per_dir_settings): + class ExplodingProvider(FakePerDirProvider): + def get_repo_settings_tree(self, ref): + raise RuntimeError("boom") + + provider = ExplodingProvider(files=["services/api.py"]) + + assert git_utils._get_per_directory_settings(provider) == [] + + +class TestApplyPerDirectorySettings: + def test_merge_root_then_directory_nearest_wins(self, per_dir_settings, monkeypatch): + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml", "services/auth/.pr_agent.toml"], + contents={ + "services/.pr_agent.toml": SERVICES_TOML, + "services/auth/.pr_agent.toml": SERVICES_AUTH_TOML, + }, + files=["services/auth/api.py", "services/billing/x.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # Nearest (services/auth) wins over services, which wins over root. + assert get_settings().pr_reviewer.num_max_findings == 3 + assert get_settings().config.temperature == 0.5 + assert get_settings().config.model == "auth-model" + + def test_different_key_casing_overrides_root_value(self, per_dir_settings, monkeypatch): + # Dynaconf resolves keys case-insensitively, so a per-directory file that spells + # the same key with different casing must replace the root value, not join it as a + # duplicate that leaves the old value winning (regression for "nearest wins"). + nested = b""" +[pr_reviewer] +Num_Max_Findings = 4 +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=b"[pr_reviewer]\nnum_max_findings = 10\n", + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": nested}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().pr_reviewer.num_max_findings == 4 + + def test_list_values_replace_not_concatenate(self, per_dir_settings, monkeypatch): + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": SERVICES_TOML}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # The default [ignore] glob ('vendor/**') must be replaced by the per-directory + # file wholesale, not concatenated with it. + assert get_settings().ignore.glob == ["*.gen.js"] + + def test_whitelist_blocks_secrets_and_critical_sections(self, per_dir_settings, monkeypatch): + evil = b""" +[openai] +api_base = "https://evil.example.com" + +[config] +model = "allowed-model" +git_provider = "gitlab" + +[push_outputs] +webhook_url = "https://evil.example.com/hook" + +[pr_reviewer] +num_max_findings = 2 +publish_error_details = true +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": evil}, + files=["services/api.py"], + ), + ) + # Baseline: ensure inherited settings from earlier tests don't mask a leak. + get_settings().config.git_provider = "github" + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # [openai], [push_outputs] and config.git_provider are not per-directory overridable. + assert get_settings().get("openai.api_base", None) != "https://evil.example.com" + assert get_settings().config.git_provider != "gitlab" + assert get_settings().get("push_outputs.webhook_url", None) != "https://evil.example.com/hook" + # Allowed keys still land, and the repo-host-only pr_reviewer key stays dropped. + assert get_settings().config.model == "allowed-model" + assert get_settings().pr_reviewer.num_max_findings == 2 + assert get_settings().pr_reviewer.publish_error_details is False + + def test_whitelist_rejects_unknown_sections_entirely(self, per_dir_settings, monkeypatch): + config = b""" +[openai] +api_base = "https://evil.example.com" + +[pr_reviewer] +num_max_findings = 4 +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=b"", + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().get("openai.api_base", None) != "https://evil.example.com" + assert get_settings().pr_reviewer.num_max_findings == 4 + + def test_write_or_url_trigger_keys_are_dropped_in_per_directory(self, per_dir_settings, monkeypatch): + config = b""" +[pr_update_changelog] +push_changelog_changes = true +add_pr_link = true +extra_instructions = "keep-me" + +[pr_help_docs] +repo_url = "https://evil.example.com/steal" +docs_path = "custom-docs" +exclude_root_readme = true +supported_doc_exts = [".md"] +enable_help_text = true +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().pr_update_changelog.push_changelog_changes is False + assert get_settings().get("pr_help_docs.repo_url", "") == "" + # Collection scope is root-/host-controlled: a nested file cannot point /help_docs + # at arbitrary repository paths or file extensions to read into the model prompt. + assert get_settings().pr_help_docs.docs_path == "docs" + assert get_settings().pr_help_docs.supported_doc_exts == [".md", ".mdx", ".rst"] + assert get_settings().pr_update_changelog.add_pr_link is True + assert get_settings().pr_update_changelog.extra_instructions == "keep-me" + assert get_settings().pr_help_docs.exclude_root_readme is True + assert get_settings().pr_help_docs.enable_help_text is True + + def test_description_questions_and_similar_issue_host_only_keys_are_dropped( + self, per_dir_settings, monkeypatch + ): + config = b""" +[pr_reviewer] +num_max_findings = 4 +enable_large_pr_chunking = true +max_number_of_calls = 9999 +inline_key_issues = true +enable_review_labels_security = false +enable_review_labels_effort = false +require_security_review = false +require_estimate_effort_to_review = false +require_ticket_analysis_review = false + +[pr_description] +publish_labels = true +use_ai_title = true +generate_ai_title = true +publish_description_as_comment = true +publish_description_as_comment_persistent = false +enable_large_pr_handling = true +max_ai_calls = 400 +async_ai_calls = false + +[pr_questions] +resolve_threads = true +static_questions = ["default"] +use_conversation_history = true + +[pr_code_suggestions] +commitable_code_suggestions = true +num_code_suggestions_per_chunk = 2 +max_number_of_calls = 99 +parallel_calls = true +demand_code_suggestions_self_review = true +approve_pr_on_self_review = true + +[pr_similar_issue] +force_update_dataset = true +skip_comments = false +max_issues_to_scan = 999999 +vectordb = "pinecone" +use_original_title = false +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML + b""" +[pr_questions] +use_conversation_history = false +[pr_similar_issue] +skip_comments = true +""", + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # Label mutation, thread resolution and full issue-index refresh switches are + # host-/root-controlled and must stay at their trusted defaults. + assert get_settings().pr_description.publish_labels is False + assert get_settings().pr_questions.resolve_threads is False + assert get_settings().pr_similar_issue.force_update_dataset is False + assert get_settings().pr_similar_issue.skip_comments is True + assert get_settings().get("pr_similar_issue.max_issues_to_scan", 0) != 999999 + assert get_settings().pr_similar_issue.vectordb != "pinecone" + # Pull-request metadata and reviewer label/inline controls stay trusted: + # a nested file cannot rewrite PR titles/bodies, publish reviewer effort or + # security labels, or open inline key-issue comments through the bot identity. + assert get_settings().pr_description.generate_ai_title is False + assert get_settings().pr_description.publish_description_as_comment is False + assert get_settings().pr_description.publish_description_as_comment_persistent is True + assert get_settings().pr_reviewer.inline_key_issues is False + assert get_settings().pr_reviewer.enable_review_labels_security is True + assert get_settings().pr_reviewer.enable_review_labels_effort is True + assert get_settings().pr_reviewer.require_security_review is True + assert get_settings().pr_reviewer.require_estimate_effort_to_review is True + assert get_settings().pr_reviewer.require_ticket_analysis_review is True + # Budget/call-count controls stay at the host-trusted values: nested files must + # not multiply AI calls on their own. + assert get_settings().pr_reviewer.enable_large_pr_chunking is False + assert get_settings().pr_reviewer.max_number_of_calls != 9999 + assert get_settings().pr_description.enable_large_pr_handling != 999999 + assert get_settings().pr_description.max_ai_calls != 400 + assert get_settings().pr_description.async_ai_calls is True + assert get_settings().pr_code_suggestions.max_number_of_calls != 99 + assert get_settings().pr_code_suggestions.parallel_calls is True + # The self-review approval workflow stays root-controlled: a nested file cannot + # demand a self-review checklist and then auto-approve on the author's tick. + assert get_settings().pr_code_suggestions.demand_code_suggestions_self_review is False + assert get_settings().pr_code_suggestions.approve_pr_on_self_review is False + # Thread-history collection for /ask is root-controlled: a nested file cannot + # re-enable sending private review-thread discussion bodies to the model. + assert get_settings().pr_questions.use_conversation_history is False + # Ordinary keys in the same sections still apply. + assert get_settings().pr_reviewer.num_max_findings == 4 + assert get_settings().pr_description.use_ai_title is True + assert get_settings().pr_questions.static_questions == ["default"] + assert get_settings().pr_code_suggestions.commitable_code_suggestions is False + assert get_settings().pr_code_suggestions.num_code_suggestions_per_chunk == 2 + assert get_settings().pr_similar_issue.use_original_title is False + + def test_per_directory_ignore_allows_only_glob(self, per_dir_settings, monkeypatch): + config = b""" +[ignore] +glob = ["gen/**"] +regex = ["(a+)+$"] +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # Bounded glob patterns land; arbitrary regexes (which filter_ignored() compiles + # and matches on every review) stay at the root default so a nested file cannot + # commit a catastrophic-backtracking pattern. + assert get_settings().ignore.glob == ["gen/**"] + assert get_settings().ignore.regex == [] + + def test_per_directory_config_drops_unbounded_repo_context_knobs(self, per_dir_settings, monkeypatch): + config = b""" +[config] +model = "nested-model" +temperature = 0.5 +repo_context_files = ["huge.bin", "secrets.env", "vendor/data.bin"] +repo_context_max_lines = 9999999 +model_token_count_estimate_factor = -0.999999 +per_directory_settings_max_tree_pages = 999999 +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # Context-fetch knobs are root-/host-controlled; a nested file cannot make tools + # fetch arbitrary repo files in full when they build context for its directory. + assert get_settings().get("config.repo_context_files", []) != [ + "huge.bin", + "secrets.env", + "vendor/data.bin", + ] + assert get_settings().get("config.repo_context_max_lines", 0) != 9999999 + assert get_settings().config.model_token_count_estimate_factor == 0.3 + assert get_settings().config.per_directory_settings_max_tree_pages == 10 + # Model-routing and output knobs still apply. + assert get_settings().config.model == "nested-model" + assert get_settings().config.temperature == 0.5 + + def test_per_directory_config_drops_non_string_model_values(self, per_dir_settings, monkeypatch): + trusted_model_weak = get_settings().get("config.model_weak", None) + config = b""" +[config] +model = 1 +model_weak = false +model_reasoning = "reasoning-model" +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().config.model == "root-model" + assert get_settings().get("config.model_weak", None) == trusted_model_weak + assert get_settings().config.model_reasoning == "reasoning-model" + + @pytest.mark.parametrize("invalid_value", [(1, "1"), (False, "false"), (["fr-fr"], '["fr-fr"]'), + ({"locale": "fr-fr"}, '{locale = "fr-fr"}')]) + def test_per_directory_config_drops_non_string_response_language(self, per_dir_settings, monkeypatch, + invalid_value): + _, toml_value = invalid_value + config = f"[config]\nresponse_language = {toml_value}\n".encode() + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().get("config.response_language", "en-us") == "en-US" + + @pytest.mark.parametrize("toml_value", [ + '"hot"', "true", "[0.1]", '{value = "0.1"}', "nan", "-0.1", "2.1", + ]) + def test_per_directory_config_drops_invalid_temperature(self, per_dir_settings, monkeypatch, toml_value): + config = f"[config]\ntemperature = {toml_value}\n".encode() + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().config.temperature == 0.1 + + @pytest.mark.parametrize("toml_value", ['"six"', "true", "-1", "1001", "1.5"]) + def test_per_directory_config_drops_invalid_description_threshold(self, per_dir_settings, monkeypatch, + toml_value): + config = f"[pr_description]\ncollapsible_file_list_threshold = {toml_value}\n".encode() + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert get_settings().get("pr_description.collapsible_file_list_threshold") == 6 + + def test_per_directory_config_drops_fallback_models(self, per_dir_settings, monkeypatch): + config = b""" +[config] +model = "nested-model" +temperature = 0.5 +fallback_models = ["m-one", "m-two", "m-three", "m-four", "m-five"] +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # Fallback routing stays root-/host-controlled: the retry helper turns each + # entry into one more routed attempt per failing model, so a nested file must + # not be able to multiply AI calls with an arbitrarily long list. + assert list(get_settings().config.fallback_models) == ["gpt-5.6-terra"] + # Other model-routing and output knobs still apply. + assert get_settings().config.model == "nested-model" + assert get_settings().config.temperature == 0.5 + + def test_per_directory_config_drops_token_budget_keys(self, per_dir_settings, monkeypatch): + config = b""" +[config] +model = "nested-model" +temperature = 0.5 +max_model_tokens = 999999 +custom_model_max_tokens = 999999 +max_output_tokens = 999999 +""" + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": config}, + files=["services/api.py"], + ), + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + # Token budgets are root-/host-controlled because they directly size request + # and completion limits; a nested file cannot inflate the cost of every call. + assert get_settings().config.max_model_tokens != 999999 + assert get_settings().config.custom_model_max_tokens != 999999 + assert get_settings().config.max_output_tokens != 999999 + # Model-routing and output knobs still apply. + assert get_settings().config.model == "nested-model" + assert get_settings().config.temperature == 0.5 + + def test_malformed_per_directory_config_reports_error(self, per_dir_settings, monkeypatch): + malformed = b"[pr_reviewer\nnum_max_findings = 2\n" + provider = _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": malformed}, + files=["services/api.py"], + ) + comments = [] + provider.publish_persistent_comment = lambda *args, **kwargs: comments.append((args, kwargs)) + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: provider, + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert len(comments) == 1 + assert "services/.pr_agent.toml" in comments[0][0][0] + # The root config still applied before the malformed per-directory file. + assert get_settings().pr_reviewer.num_max_findings == 10 + + def test_per_directory_inert_when_feature_disabled(self, fresh_global_settings, monkeypatch): + with request_cycle_context({}): + context["settings"] = copy.deepcopy(global_settings) + provider = _provider( + root_settings=ROOT_TOML, + tree_paths=["services/.pr_agent.toml"], + contents={"services/.pr_agent.toml": SERVICES_AUTH_TOML}, + files=["services/auth/api.py"], + ) + monkeypatch.setattr( + "pr_agent.git_providers.utils.get_git_provider_with_context", + lambda url: provider, + ) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + + assert provider.tree_calls == 0 + assert provider.get_files_calls == 0 + assert get_settings().pr_reviewer.num_max_findings == 10 + + +def _github_provider(repo_obj): + provider = GithubProvider.__new__(GithubProvider) + provider.repo_obj = repo_obj + provider._resolved_config_branch = None + return provider + + +class TestGithubProviderPerDirectory: + def test_get_repo_settings_tree_filters_pr_agent_toml_blobs(self): + repo_obj = MagicMock() + repo_obj.default_branch = "main" + repo_obj.get_git_tree.return_value = SimpleNamespace(tree=[ + SimpleNamespace(path=".pr_agent.toml", type="blob"), + SimpleNamespace(path="services/auth/.pr_agent.toml", type="blob"), + SimpleNamespace(path="services/auth/code.py", type="blob"), + SimpleNamespace(path="services/auth", type="tree"), + ]) + provider = _github_provider(repo_obj) + + paths, resolved_ref = provider.get_repo_settings_tree() + + assert resolved_ref == "main" + assert paths == [".pr_agent.toml", "services/auth/.pr_agent.toml"] + repo_obj.get_git_tree.assert_called_once_with("main", recursive=True) + + def test_get_repo_settings_tree_uses_resolved_config_branch(self): + repo_obj = MagicMock() + repo_obj.get_git_tree.return_value = SimpleNamespace(tree=[]) + provider = _github_provider(repo_obj) + provider._resolved_config_branch = "cfg-branch" + + _, resolved_ref = provider.get_repo_settings_tree("") + + assert resolved_ref == "cfg-branch" + repo_obj.get_git_tree.assert_called_once_with("cfg-branch", recursive=True) + + def test_resolved_config_branch_beats_explicit_ref(self): + # get_repo_settings() stores the branch it actually read the root config + # from (already fallback-resolved), so it must win over a CONFIG_BRANCH hint: + # when that branch exists without a root .pr_agent.toml the tree must follow + # the root config onto the default branch instead of reading a stale branch. + repo_obj = MagicMock() + repo_obj.get_git_tree.return_value = SimpleNamespace(tree=[], truncated=False) + provider = _github_provider(repo_obj) + provider._resolved_config_branch = "resolved-default" + + _, resolved_ref = provider.get_repo_settings_tree("stale-config-branch") + + assert resolved_ref == "resolved-default" + repo_obj.get_git_tree.assert_called_once_with("resolved-default", recursive=True) + + def test_truncated_tree_skips_per_directory_settings(self): + from loguru import logger as loguru_logger + + repo_obj = MagicMock() + repo_obj.get_git_tree.return_value = SimpleNamespace( + tree=[SimpleNamespace(path="svc/.pr_agent.toml", type="blob")], + truncated=True, + ) + provider = _github_provider(repo_obj) + + captured_lines = [] + sink_id = loguru_logger.add( + lambda msg: captured_lines.append(str(msg)), + level="WARNING", + ) + try: + paths, resolved_ref = provider.get_repo_settings_tree("big-branch") + finally: + loguru_logger.remove(sink_id) + + assert paths == [] + assert resolved_ref == "big-branch" + assert any("truncated" in line for line in captured_lines) + + def test_get_repo_settings_tree_falls_back_to_default_on_404(self): + repo_obj = MagicMock() + repo_obj.default_branch = "main" + repo_obj.get_git_tree.side_effect = [ + GithubException(404, {"message": "Not Found"}, None), + SimpleNamespace(tree=[ + SimpleNamespace(path="svc/.pr_agent.toml", type="blob"), + ]), + ] + provider = _github_provider(repo_obj) + + paths, resolved_ref = provider.get_repo_settings_tree("missing-branch") + + assert resolved_ref == "main" + assert paths == ["svc/.pr_agent.toml"] + assert repo_obj.get_git_tree.call_args_list == [ + call("missing-branch", recursive=True), + call("main", recursive=True), + ] + + def test_get_repo_settings_tree_surfaces_unexpected_errors(self): + repo_obj = MagicMock() + repo_obj.get_git_tree.side_effect = GithubException(403, {"message": "Forbidden"}, None) + provider = _github_provider(repo_obj) + + with pytest.raises(GithubException): + provider.get_repo_settings_tree("") + + def test_get_repo_settings_contents(self): + repo_obj = MagicMock() + repo_obj.get_contents.return_value = SimpleNamespace(decoded_content=b"[pr_reviewer]\nnum_max_findings = 5\n") + provider = _github_provider(repo_obj) + + result = provider.get_repo_settings_contents(["services/auth/.pr_agent.toml"], "main") + + assert result == {"services/auth/.pr_agent.toml": b"[pr_reviewer]\nnum_max_findings = 5\n"} + repo_obj.get_contents.assert_called_once_with("services/auth/.pr_agent.toml", ref="main") + + def test_get_repo_settings_contents_skips_missing_file(self): + repo_obj = MagicMock() + repo_obj.get_contents.side_effect = GithubException(404, {"message": "Not Found"}, None) + provider = _github_provider(repo_obj) + + result = provider.get_repo_settings_contents(["services/.pr_agent.toml"], "main") + + assert result == {} + + def test_get_pr_file_paths_returns_full_set_with_rename_metadata(self): + full_files = [ + SimpleNamespace(filename="services/api.py", previous_filename="legacy/api.py"), + SimpleNamespace(filename="services/fresh.py", previous_filename=None), + ] + provider = _github_provider(MagicMock()) + provider.git_files = full_files + provider.incremental = SimpleNamespace(is_incremental=True) + provider.unreviewed_files_map = {"services/api.py": "bogus-subset"} + + result = provider.get_pr_file_paths() + + # Even with an incremental review active (unreviewed_files_map populated), the + # complete PR file set with rename metadata is returned, never the reviewed subset. + assert result == full_files + assert [entry.previous_filename for entry in result] == ["legacy/api.py", None] + + +def _gitlab_provider(gl, id_project="1"): + provider = GitLabProvider.__new__(GitLabProvider) + provider.gl = gl + provider.id_project = id_project + return provider + + +class TestGitLabProviderPerDirectory: + def test_get_repo_settings_tree_filters_blobs_on_default_branch(self): + project = MagicMock() + project.default_branch = "main" + project.repository_tree.return_value = [ + {"path": ".pr_agent.toml", "type": "blob"}, + {"path": "svc/.pr_agent.toml", "type": "blob"}, + {"path": "svc/code.py", "type": "blob"}, + {"path": "svc", "type": "tree"}, + ] + gl = MagicMock() + gl.projects.get.return_value = project + provider = _gitlab_provider(gl) + + paths, resolved_ref = provider.get_repo_settings_tree() + + assert resolved_ref == "main" + assert paths == [".pr_agent.toml", "svc/.pr_agent.toml"] + project.repository_tree.assert_called_once_with(ref="main", recursive=True, page=1, per_page=100) + + def test_get_repo_settings_tree_reads_explicit_ref_when_root_did_not_resolve(self): + project = MagicMock() + project.default_branch = "main" + project.repository_tree.return_value = [{"path": "svc/.pr_agent.toml", "type": "blob"}] + gl = MagicMock() + gl.projects.get.return_value = project + provider = _gitlab_provider(gl) + + paths, resolved_ref = provider.get_repo_settings_tree("feature-config") + + assert (paths, resolved_ref) == (["svc/.pr_agent.toml"], "feature-config") + project.repository_tree.assert_called_once_with(ref="feature-config", recursive=True, page=1, per_page=100) + + @pytest.mark.parametrize("complete", [True, False]) + def test_tree_discovery_is_bounded_and_requires_complete_results(self, per_dir_settings, complete): + get_settings().config.per_directory_settings_max_tree_pages = 2 + project = MagicMock() + project.default_branch = "main" + full_page = [{"path": "svc/.pr_agent.toml", "type": "blob"}] * 100 + project.repository_tree.side_effect = [full_page, [] if complete else full_page] + gl = MagicMock() + gl.projects.get.return_value = project + + paths, ref = _gitlab_provider(gl).get_repo_settings_tree() + + assert ref == "main" + assert paths == (["svc/.pr_agent.toml"] * 100 if complete else []) + assert project.repository_tree.call_args_list == [ + call(ref="main", recursive=True, page=1, per_page=100), + call(ref="main", recursive=True, page=2, per_page=100), + ] + + def test_get_repo_settings_contents(self): + project = MagicMock() + file_obj = MagicMock() + file_obj.decode.return_value = b"[pr_reviewer]\nnum_max_findings = 5\n" + project.files.get.return_value = file_obj + gl = MagicMock() + gl.projects.get.return_value = project + provider = _gitlab_provider(gl) + + result = provider.get_repo_settings_contents(["svc/.pr_agent.toml"], "main") + + assert result == {"svc/.pr_agent.toml": b"[pr_reviewer]\nnum_max_findings = 5\n"} + project.files.get.assert_called_once_with(file_path="svc/.pr_agent.toml", ref="main") + + def test_get_repo_settings_contents_skips_missing_file(self): + from gitlab.exceptions import GitlabGetError + + project = MagicMock() + project.files.get.side_effect = GitlabGetError(response_code=404) + gl = MagicMock() + gl.projects.get.return_value = project + provider = _gitlab_provider(gl) + + result = provider.get_repo_settings_contents(["svc/.pr_agent.toml"], "main") + + assert result == {} + + def test_get_pr_file_paths_returns_full_changes_with_rename_metadata(self): + provider = _gitlab_provider(MagicMock()) + changes = [ + {"new_path": "services/api.py", "old_path": "legacy/api.py"}, + {"new_path": "services/fresh.py", "old_path": "services/fresh.py"}, + ] + provider._get_merge_request_changes = MagicMock(return_value={ + "changes": changes, + "diff_refs": {"base_sha": "base", "head_sha": "head"}, + }) + provider._expand_submodule_changes = lambda ch, diff_refs=None: ch + + result = provider.get_pr_file_paths() + + # Both sides of a rename survive, independent of incremental review state. + assert result == changes + + +@pytest.mark.parametrize("next_root", [b"", b"[pr_reviewer]\nnum_max_findings = 12\n"]) +def test_directory_overrides_do_not_leak_between_commands(fresh_global_settings, monkeypatch, next_root): + settings = get_settings() + settings.config.enable_per_directory_settings = True + settings.config.use_repo_settings_file = True + settings.pr_reviewer.num_max_findings = 10 + providers = iter([ + _provider( + tree_paths=["services/.pr_agent.toml", "services/auth/.pr_agent.toml"], + contents={ + "services/.pr_agent.toml": SERVICES_TOML, + "services/auth/.pr_agent.toml": b"[pr_reviewer]\nnum_max_findings = 3\nnew_test_key = 42\n", + }, + files=["services/auth/api.py"], + ), + _provider(tree_paths=[], contents={}, files=["README.md"], root_settings=next_root), + ]) + monkeypatch.setattr(git_utils, "get_git_provider_with_context", lambda url: next(providers)) + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/1") + assert settings.pr_reviewer.num_max_findings == 3 + assert settings.pr_reviewer.new_test_key == 42 + settings.pr_reviewer.extra_instructions = "later trusted change" + + git_utils.apply_repo_settings("https://github.com/org/repo/pull/2") + assert settings.pr_reviewer.num_max_findings == (12 if next_root else 10) + assert "new_test_key" not in settings.pr_reviewer + assert settings.pr_reviewer.extra_instructions == "later trusted change" diff --git a/tests/unittest/test_plain_diff_mode_e2e.py b/tests/unittest/test_plain_diff_mode_e2e.py index 27e07e1aab..af92ca8e95 100644 --- a/tests/unittest/test_plain_diff_mode_e2e.py +++ b/tests/unittest/test_plain_diff_mode_e2e.py @@ -3,7 +3,7 @@ import pytest from pr_agent.algo.ai_handlers.base_ai_handler import BaseAiHandler -from pr_agent.config_loader import get_settings +from pr_agent.config_loader import _find_repository_root, get_settings from pr_agent.git_providers.plain_diff_provider import PlainDiffGitProvider # Diff-mode settings keys these tests mutate on the process-wide singleton. @@ -27,6 +27,16 @@ def _set(key, value): s.set(key, value) +def test_find_repository_root_supports_git_worktree_file(tmp_path, monkeypatch): + repo_root = tmp_path / "worktree" + nested = repo_root / "src" + nested.mkdir(parents=True) + (repo_root / ".git").write_text("gitdir: /tmp/main/.git/worktrees/worktree\n") + monkeypatch.chdir(nested) + + assert _find_repository_root() == repo_root + + DIFF = """diff --git a/foo.py b/foo.py index 1111111..2222222 100644 --- a/foo.py diff --git a/tests/unittest/test_plain_diff_provider.py b/tests/unittest/test_plain_diff_provider.py index 2f2f923be9..9c9d9c7a23 100644 --- a/tests/unittest/test_plain_diff_provider.py +++ b/tests/unittest/test_plain_diff_provider.py @@ -4,6 +4,7 @@ from pr_agent.config_loader import get_settings from pr_agent.git_providers import _GIT_PROVIDERS from pr_agent.git_providers.plain_diff_provider import PlainDiffGitProvider +from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions # Diff-mode settings keys these tests mutate on the process-wide singleton. _SETTINGS_KEYS = ["plain_diff.content", "plain_diff.output_path", @@ -173,13 +174,6 @@ def test_temporary_comment_not_emitted(cfg, capsys): assert "Preparing review" not in captured.out -def test_publish_file_comments_not_supported(cfg): - cfg("plain_diff.content", DIFF) - cfg("plain_diff.output_path", None) - provider = PlainDiffGitProvider(None) - assert provider.is_supported("publish_file_comments") is False - - def test_path_traversal_file_not_read(cfg, tmp_path, monkeypatch): # SENTINEL TEST: this test FAILS if the path-traversal guard in # PlainDiffGitProvider.get_diff_files() is removed. @@ -264,9 +258,11 @@ def test_publish_code_suggestions_renders_to_stdout(cfg, capsys): # render the suggestions to stdout. assert provider.publish_code_suggestions(suggestions) is True out = capsys.readouterr().out - assert "Code suggestions" in out - assert "foo.py:2-2" in out - assert "use a constant" in out + assert out == ( + "## Code suggestions\n\n" + "### foo.py:2-2\n" + "**Suggestion:** use a constant\n\n" + ) def test_publish_code_suggestions_empty_is_noop(cfg, capsys): @@ -277,6 +273,61 @@ def test_publish_code_suggestions_empty_is_noop(cfg, capsys): assert capsys.readouterr().out.strip() == "" +@pytest.mark.asyncio +async def test_partial_improve_keeps_suggestions_and_coverage_in_one_output(cfg, capsys, tmp_path): + output = tmp_path / "suggestions.md" + cfg("plain_diff.content", DIFF) + cfg("plain_diff.output_path", str(output)) + provider = PlainDiffGitProvider(None) + assert provider.supports_code_suggestions_artifact() is True + tool = object.__new__(PRCodeSuggestions) + tool.git_provider = provider + tool.failed_chunk_count = 1 + tool.total_chunk_count = 2 + tool.progress_response = None + tool._output_published = False + tool._validate_suggestion = lambda *args: (True, "", True) + + await tool.push_inline_code_suggestions({"code_suggestions": [{ + "relevant_file": "foo.py", + "relevant_lines_start": 2, + "relevant_lines_end": 2, + "suggestion_content": "Use a constant", + "improved_code": "", + "existing_code": "line2-changed", + "label": "maintainability", + "score": 8, + }]}) + + content = output.read_text(encoding="utf-8") + stdout = capsys.readouterr().out + for rendered in (content, stdout): + assert rendered.count("Use a constant") == 1 + assert rendered.count("foo.py:2-2") == 1 + assert rendered.count("1 of 2 analysis chunks failed") == 1 + assert rendered.index("Use a constant") < rendered.index("1 of 2 analysis chunks failed") + + +@pytest.mark.asyncio +async def test_partial_improve_without_suggestions_keeps_coverage_in_artifact(cfg, capsys, tmp_path): + output = tmp_path / "suggestions.md" + cfg("plain_diff.content", DIFF) + cfg("plain_diff.output_path", str(output)) + provider = PlainDiffGitProvider(None) + tool = object.__new__(PRCodeSuggestions) + tool.git_provider = provider + tool.failed_chunk_count = 1 + tool.total_chunk_count = 2 + + await tool.publish_no_suggestions() + + content = output.read_text(encoding="utf-8") + stdout = capsys.readouterr().out + for rendered in (content, stdout): + assert rendered.count("No code suggestions found in the successfully analyzed chunks.") == 1 + assert rendered.count("1 of 2 analysis chunks failed") == 1 + + def test_incremental_review_disabled(cfg): # -i has no meaning for a standalone diff; the provider must disable it so # PRReviewer never takes the incremental path (which would TypeError). diff --git a/tests/unittest/test_pr_add_docs_prompt_contract.py b/tests/unittest/test_pr_add_docs_prompt_contract.py new file mode 100644 index 0000000000..bb63c5c8d9 --- /dev/null +++ b/tests/unittest/test_pr_add_docs_prompt_contract.py @@ -0,0 +1,14 @@ +import yaml + +from pr_agent.config_loader import get_settings + + +def test_add_docs_example_uses_singular_relevant_line(): + prompt = get_settings().pr_add_docs_prompt.system + example_section = prompt.partition("Example output:")[2] + example = example_section.partition("```yaml")[2].partition("```")[0] + + documentation = yaml.safe_load(example)["Code Documentation"][0] + + assert documentation["relevant line"] == 12 + assert "relevant lines" not in documentation diff --git a/tests/unittest/test_pr_agent_routing.py b/tests/unittest/test_pr_agent_routing.py index efd904e87a..1b6df8a969 100644 --- a/tests/unittest/test_pr_agent_routing.py +++ b/tests/unittest/test_pr_agent_routing.py @@ -31,6 +31,15 @@ def _patch_request_dependencies(monkeypatch, validate_result=(True, None), updat monkeypatch.setattr(pr_agent_module, "update_settings_from_args", update_settings_fn) +def _incomplete_files_provider(comments=()): + provider = Mock() + provider.get_issue_comments_newest_first.return_value = list(comments) + provider._get_comment_body.side_effect = lambda comment: comment.get("body", "") + provider.is_comment_authored_by_pr_agent.return_value = False + provider.supports_html_comment_markers.return_value = True + return provider + + @pytest.mark.asyncio async def test_handle_request_routes_known_command_and_notifies(monkeypatch): runs = [] @@ -181,6 +190,159 @@ async def run(self): assert handled is False +@pytest.mark.asyncio +async def test_incomplete_github_files_constructor_error_publishes_sanitized_notice(monkeypatch): + secret = "private/repo mismatch: expected 5001 files but fetched 3000" + provider = _incomplete_files_provider() + + class IncompleteTool: + def __init__(self, pr_url, ai_handler, args): + raise pr_agent_module.IncompletePullRequestFilesError(secret) + + _patch_request_dependencies(monkeypatch) + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + monkeypatch.setitem(pr_agent_module.command2class, "custom", IncompleteTool) + + handled = await pr_agent_module.PRAgent()._handle_request( + "https://example/pr/1", "/custom" + ) + + assert handled is False + provider.publish_comment.assert_called_once() + published = provider.publish_comment.call_args.args[0] + assert "GitHub returned an incomplete or inconsistent changed-file set" in published + assert "If this pull request changes more than 3,000 files" in published + assert "Otherwise, retry the command" in published + assert "command was not run" in published + assert secret not in published + assert pr_agent_module.INCOMPLETE_GITHUB_FILES_COMMENT_MARKER in published.splitlines()[:5] + + +@pytest.mark.asyncio +async def test_unexpected_constructor_error_does_not_publish_incomplete_files_notice(monkeypatch): + provider_factory = Mock() + + class BrokenTool: + def __init__(self, pr_url, ai_handler, args): + raise RuntimeError("unrelated") + + _patch_request_dependencies(monkeypatch) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", provider_factory) + monkeypatch.setitem(pr_agent_module.command2class, "custom", BrokenTool) + + handled = await pr_agent_module.PRAgent()._handle_request( + "https://example/pr/1", "/custom" + ) + + assert handled is False + provider_factory.assert_not_called() + + +def test_incomplete_files_notice_deduplicates_trusted_agent_comment(monkeypatch): + existing = { + "body": ( + "## Existing notice\n\n" + f"{pr_agent_module.INCOMPLETE_GITHUB_FILES_COMMENT_MARKER}\n\nDetails" + ) + } + provider = _incomplete_files_provider([existing]) + provider.is_comment_authored_by_pr_agent.return_value = True + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + + pr_agent_module.publish_incomplete_github_files_comment("https://example/pr/1") + + provider.publish_comment.assert_not_called() + + +def test_foreign_incomplete_files_marker_does_not_suppress_notice(monkeypatch): + existing = { + "body": ( + "## Spoofed notice\n\n" + f"{pr_agent_module.INCOMPLETE_GITHUB_FILES_COMMENT_MARKER}\n\nDetails" + ) + } + provider = _incomplete_files_provider([existing]) + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + + pr_agent_module.publish_incomplete_github_files_comment("https://example/pr/1") + + provider.publish_comment.assert_called_once() + + +def test_incomplete_files_notice_fails_open_when_author_cannot_be_verified(monkeypatch): + existing = { + "body": ( + "## Existing notice\n\n" + f"{pr_agent_module.INCOMPLETE_GITHUB_FILES_COMMENT_MARKER}\n\nDetails" + ) + } + provider = _incomplete_files_provider([existing]) + provider.is_comment_authored_by_pr_agent.side_effect = RuntimeError("identity unavailable") + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + + pr_agent_module.publish_incomplete_github_files_comment("https://example/pr/1") + + provider.publish_comment.assert_called_once() + + +def test_incomplete_files_notice_fails_open_when_comment_lookup_fails(monkeypatch): + provider = _incomplete_files_provider() + provider.get_issue_comments_newest_first.side_effect = RuntimeError("lookup unavailable") + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + + pr_agent_module.publish_incomplete_github_files_comment("https://example/pr/1") + + provider.publish_comment.assert_called_once() + + +def test_incomplete_files_notice_fails_open_when_comment_body_cannot_be_read(monkeypatch): + provider = _incomplete_files_provider([object()]) + provider._get_comment_body.side_effect = RuntimeError("comment decoding failed") + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + + pr_agent_module.publish_incomplete_github_files_comment("https://example/pr/1") + + provider.publish_comment.assert_called_once() + + +@pytest.mark.asyncio +async def test_incomplete_files_notice_publication_failure_keeps_command_failed(monkeypatch): + provider = _incomplete_files_provider() + provider.publish_comment.side_effect = RuntimeError("publication unavailable") + + class IncompleteTool: + def __init__(self, pr_url, ai_handler, args): + raise pr_agent_module.IncompletePullRequestFilesError("internal details") + + _patch_request_dependencies(monkeypatch) + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _pr_url: provider) + monkeypatch.setitem(pr_agent_module.command2class, "custom", IncompleteTool) + + handled = await pr_agent_module.PRAgent()._handle_request( + "https://example/pr/1", "/custom" + ) + + assert handled is False + provider.publish_comment.assert_called_once() + + +def test_incomplete_files_notice_respects_disabled_output(monkeypatch): + provider_factory = Mock() + monkeypatch.setattr(get_settings().config, "publish_output", False, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", provider_factory) + + pr_agent_module.publish_incomplete_github_files_comment("https://example/pr/1") + + provider_factory.assert_not_called() + + @pytest.mark.asyncio async def test_handle_request_wrapper_returns_false_on_exception(monkeypatch): async def raise_error(self, pr_url, request, notify=None): @@ -310,3 +472,37 @@ async def test_handle_request_returns_false_for_unknown_command(monkeypatch): handled = await pr_agent_module.PRAgent()._handle_request("https://example/pr/1", "/unknown") assert handled is False + + +@pytest.mark.asyncio +async def test_handle_request_language_instruction_preserves_control_values(monkeypatch): + settings = get_settings() + original = { + key: settings.get(key).extra_instructions + for key in settings + if hasattr(settings.get(key), "extra_instructions") + } + settings.config.response_language = "de-DE" + + class FakeReviewer: + def __init__(self, pr_url, is_answer=False, is_auto=False, args=None, ai_handler=None): + pass + + async def run(self): + pass + + try: + _patch_request_dependencies(monkeypatch) + monkeypatch.setattr(pr_agent_module, "PRReviewer", FakeReviewer) + + await pr_agent_module.PRAgent()._handle_request("https://example/pr/1", "/review") + + instructions = str(settings.pr_reviewer.extra_instructions) + assert "de-DE" in instructions + assert "Keep schema control values" in instructions + assert "'No'" in instructions + assert "do not translate them" in instructions + finally: + settings.config.response_language = "en-us" + for key, value in original.items(): + settings.get(key).extra_instructions = value diff --git a/tests/unittest/test_pr_code_suggestions_core.py b/tests/unittest/test_pr_code_suggestions_core.py index 6dffc7f9c3..23ed3f13cf 100644 --- a/tests/unittest/test_pr_code_suggestions_core.py +++ b/tests/unittest/test_pr_code_suggestions_core.py @@ -4,10 +4,13 @@ import pytest +import pr_agent.algo.token_budget as token_budget_module import pr_agent.tools.pr_code_suggestions as pr_code_suggestions_module +from pr_agent.algo.comment_identity import PRCodeSuggestionsHeader, PRCodeSuggestionsIdentity from pr_agent.algo.pr_processing import pr_generate_extended_diff, retry_with_fallback_models +from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.types import FilePatchInfo -from pr_agent.algo.utils import PRCodeSuggestionsHeader, PRCodeSuggestionsIdentity, load_large_diff +from pr_agent.algo.utils import load_large_diff from pr_agent.config_loader import get_settings from pr_agent.git_providers import AzureDevopsProvider from pr_agent.git_providers.git_provider import GitProvider, IncrementalPR @@ -15,9 +18,18 @@ from tests.unittest._settings_helpers import restore_settings, snapshot_settings +@pytest.fixture(autouse=True) +def _known_model_windows(monkeypatch): + monkeypatch.setattr(token_budget_module, "get_max_tokens", lambda model, **kwargs: 10_000) + + def _make_tool(git_provider=None): tool = PRCodeSuggestions.__new__(PRCodeSuggestions) tool.git_provider = git_provider or MagicMock() + tool.ai_handler = MagicMock() + tool.vars = {"diff": "", "diff_no_line_numbers": ""} + tool.pr_code_suggestions_prompt_system = "Review the pull request" + tool.pr_code_suggestions_prompt_user = "{{ diff_no_line_numbers }}" tool.progress_response = None return tool @@ -37,6 +49,27 @@ def _valid_suggestion(**overrides): return suggestion +@pytest.mark.asyncio +async def test_get_prediction_rejects_a_clipped_suggestion_chunk(): + tool = _make_tool() + tool.ai_handler.chat_completion = AsyncMock() + tool._suggestion_attempt_budget = SimpleNamespace( + model="attempt-model", + fit_optional_text=MagicMock( + return_value=SimpleNamespace( + optional_text="clipped diff", + system_prompt="system", + user_prompt="user", + ) + ), + ) + + with pytest.raises(ValueError, match="complete suggestion chunk"): + await tool._get_prediction("attempt-model", "numbered diff", "complete diff") + + tool.ai_handler.chat_completion.assert_not_awaited() + + def test_prepare_pr_code_suggestions_filters_duplicates_and_missing_required_fields(): tool = _make_tool() prediction = """ @@ -156,6 +189,71 @@ async def test_convert_to_decoupled_preserves_quoted_file_headings_across_files( assert "2 +new first" not in second_section +@pytest.mark.asyncio +async def test_convert_to_decoupled_uses_fallback_model_budget_and_tokenizer(monkeypatch): + counted_models = [] + reserve_calls = [] + window_calls = [] + + class ModelBoundTokenHandler(TokenHandler): + def __init__(self, model="primary-model"): + super().__init__(model=model) + self.prompt_tokens = 10 if model == "fallback-model" else 0 + + def for_model(self, model): + return ModelBoundTokenHandler(model) + + def count_tokens(self, text, force_accurate=False): + counted_models.append(self.model) + return len(text) if self.model == "fallback-model" else 1 + + def get_window(model, ignore_max_model_tokens=False): + window_calls.append((model, ignore_max_model_tokens)) + return 2_100 + + def get_output_token_reserve(model, default): + reserve_calls.append((model, default)) + return default + + monkeypatch.setattr(token_budget_module, "get_max_tokens", get_window) + tool = _make_tool() + tool.token_handler = ModelBoundTokenHandler() + tool.ai_handler = SimpleNamespace(get_output_token_reserve=get_output_token_reserve) + patch_prompt = "## File: 'app.py'\n\n@@ -1 +1 @@\n-old\n+" + "replacement " * 40 + + result = await tool.convert_to_decoupled_with_line_numbers([patch_prompt], "fallback-model") + + assert len(result) == 1 + assert result[0] + assert len(result[0]) <= 90 + assert "replacement " * 40 not in result[0] + assert counted_models and set(counted_models) == {"fallback-model"} + assert reserve_calls == [("fallback-model", 2_000)] + assert window_calls == [("fallback-model", True)] + + +@pytest.mark.asyncio +async def test_convert_to_decoupled_reuses_supplied_attempt_budget(): + tool = _make_tool() + tool.token_handler = MagicMock() + attempt_budget = SimpleNamespace( + available_tokens=MagicMock(return_value=1_000), + count_tokens=MagicMock(return_value=1), + ) + patch_prompt = "## File: 'app.py'\n\n@@ -1 +1 @@\n-old\n+new" + + result = await tool.convert_to_decoupled_with_line_numbers( + [patch_prompt], + "fallback-model", + attempt_budget=attempt_budget, + ) + + assert len(result) == 1 + assert "1 +new" in result[0] + attempt_budget.available_tokens.assert_called_once_with(2_000, preserve_minimum=True) + attempt_budget.count_tokens.assert_called_once_with(result[0]) + + @pytest.mark.asyncio async def test_prepare_prediction_main_caps_suggestions_per_file_after_chunk_merge(): settings_snapshot = snapshot_settings(( @@ -177,7 +275,9 @@ async def fake_get_prediction(model, patches_diff, patches_diff_no_line_numbers) )]} try: - with patch.object(pr_code_suggestions_module, "get_pr_multi_diffs", return_value=["chunk-a", "chunk-b"]): + with patch.object( + pr_code_suggestions_module, "get_pr_multi_diffs", return_value=["chunk-a", "chunk-b"] + ) as get_pr_multi_diffs: tool._get_prediction = fake_get_prediction data = await tool.prepare_prediction_main("primary-model") @@ -188,6 +288,7 @@ async def fake_get_prediction(model, patches_diff, patches_diff_no_line_numbers) one_sentence_summary="Finding from chunk-a", relevant_lines_start=1, )] + assert get_pr_multi_diffs.call_args.kwargs["output_token_reserve"] is tool.ai_handler.get_output_token_reserve def test_limit_suggestions_per_file_keeps_highest_scores_and_preserves_other_files(): @@ -274,6 +375,60 @@ async def test_analyze_self_reflection_response_merges_scores_and_zeroes_invalid settings.config.publish_output = original_publish_output +@pytest.mark.asyncio +async def test_self_reflection_skips_model_when_required_prompt_exceeds_budget(monkeypatch): + tool = _make_tool() + tool.ai_handler.chat_completion = AsyncMock() + + class RequiredPromptOverflow: + def fit_prompt_variable(self, *_args, **_kwargs): + raise ValueError("required prompt exceeds budget") + + monkeypatch.setattr( + pr_code_suggestions_module.AttemptTokenBudget, + "for_prompt_attempt", + lambda *_args, **_kwargs: RequiredPromptOverflow(), + ) + + result = await tool.self_reflect_on_suggestions( + [_valid_suggestion()], + "numbered diff", + "fallback-model", + ) + + assert result == "" + tool.ai_handler.chat_completion.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_self_reflection_skips_model_when_numbered_diff_is_clipped(monkeypatch): + tool = _make_tool() + tool.ai_handler.chat_completion = AsyncMock() + + class ClippedReflectionDiff: + def fit_prompt_variable(self, *_args, **_kwargs): + return SimpleNamespace( + optional_text="numbered", + system_prompt="system", + user_prompt="user", + ) + + monkeypatch.setattr( + pr_code_suggestions_module.AttemptTokenBudget, + "for_prompt_attempt", + lambda *_args, **_kwargs: ClippedReflectionDiff(), + ) + + result = await tool.self_reflect_on_suggestions( + [_valid_suggestion()], + "numbered diff", + "fallback-model", + ) + + assert result == "" + tool.ai_handler.chat_completion.assert_not_awaited() + + @pytest.mark.asyncio async def test_prepare_prediction_main_keeps_successful_chunks_when_one_parallel_chunk_fails(): settings = get_settings() @@ -445,10 +600,11 @@ async def fake_get_prediction(model, patches_diff, patches_diff_no_line_numbers) return {"code_suggestions": [_valid_suggestion(relevant_file=f"chunk-{len(chunk_pairs)}.py")]} try: - with patch.object(pr_code_suggestions_module, "get_pr_multi_diffs", side_effect=[ - ["stale unnumbered chunk"], - ["1 fallback-a", "2 fallback-b"], - ]): + with patch.object( + pr_code_suggestions_module, + "get_pr_multi_diffs", + side_effect=[["stale unnumbered chunk"], ["1 fallback-a", "2 fallback-b"]], + ) as get_pr_multi_diffs: tool._get_prediction = fake_get_prediction data = await tool.prepare_prediction_main("primary-model") @@ -462,6 +618,16 @@ async def fake_get_prediction(model, patches_diff, patches_diff_no_line_numbers) ] assert tool.total_chunk_count == 2 assert len(data["code_suggestions"]) == 2 + assert len(get_pr_multi_diffs.call_args_list) == 2 + tool.convert_to_decoupled_with_line_numbers.assert_awaited_once_with( + ["stale unnumbered chunk"], + "primary-model", + attempt_budget=tool._suggestion_attempt_budget, + ) + assert all( + call.kwargs["output_token_reserve"] is tool.ai_handler.get_output_token_reserve + for call in get_pr_multi_diffs.call_args_list + ) def test_suggestions_coverage_footer_reports_partial_runs_and_respects_flag(): @@ -914,10 +1080,10 @@ async def test_publish_no_suggestions_removes_the_progress_comment_when_quiet(pu git_provider.publish_comment.assert_not_called() -def _provider_with_file(head_file, filename="app.py"): +def _provider_with_file(head_file, filename="app.py", patch=""): git_provider = MagicMock() git_provider.diff_files = [ - FilePatchInfo(base_file="", head_file=head_file, patch="", filename=filename) + FilePatchInfo(base_file="", head_file=head_file, patch=patch, filename=filename) ] git_provider.publish_code_suggestions.return_value = True return git_provider @@ -930,7 +1096,11 @@ def _published_suggestion(git_provider): def test_summarized_suggestions_use_the_target_file_indentation(): - git_provider = _provider_with_file("func f() {\n\told()\n}\n", filename="main.go") + git_provider = _provider_with_file( + "func f() {\n\told()\n}\n", + filename="main.go", + patch="@@ -1,3 +1,3 @@\n func f() {\n-\told()\n+\tnew()\n }\n", + ) git_provider.get_line_link.return_value = "https://example.com/main.go#L2" tool = _make_tool(git_provider) suggestion = _valid_suggestion( @@ -980,6 +1150,7 @@ def test_summarized_suggestions_normalize_both_sides_of_the_diff(): git_provider = _provider_with_file( "func f() {\n\tif old() {\n\t\tkeep()\n\t}\n}\n", filename="main.go", + patch="@@ -1,5 +1,5 @@\n func f() {\n-\tif old() {\n+\tif new() {\n \t\tkeep()\n \t}\n }\n", ) git_provider.get_line_link.return_value = "https://example.com/main.go#L2-L4" tool = _make_tool(git_provider) @@ -1018,6 +1189,26 @@ async def test_suggestion_covering_the_anchored_range_is_published_as_committabl assert "```suggestion\n return new()\n```" in _published_suggestion(git_provider)["body"] +@pytest.mark.asyncio +async def test_aligned_original_suggestion_matches_rendered_fence_indentation(): + git_provider = _provider_with_file("def f():\n return old()\n") + tool = _make_tool(git_provider) + + await tool.push_inline_code_suggestions({"code_suggestions": [ + _valid_suggestion( + relevant_lines_start=2, + relevant_lines_end=2, + existing_code="return old()", + improved_code="return new()", + score=8, + ) + ]}) + + original = _published_suggestion(git_provider)["original_suggestion"] + assert original["existing_code"] == " return old()" + assert original["improved_code"] == " return new()" + + @pytest.mark.asyncio @pytest.mark.parametrize("improved_code", ["return new(", "return await new()"]) async def test_invalid_python_replacement_is_published_as_a_pr_comment(improved_code): diff --git a/tests/unittest/test_pr_code_suggestions_lifecycle.py b/tests/unittest/test_pr_code_suggestions_lifecycle.py index 973c19422d..d3a34646d0 100644 --- a/tests/unittest/test_pr_code_suggestions_lifecycle.py +++ b/tests/unittest/test_pr_code_suggestions_lifecycle.py @@ -4,7 +4,9 @@ import pytest +from pr_agent.algo.types import FilePatchInfo from pr_agent.config_loader import get_settings +from pr_agent.git_providers.plain_diff_provider import PlainDiffGitProvider from pr_agent.tools import pr_code_suggestions as pr_code_suggestions_module from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions from tests.unittest._settings_helpers import restore_settings, snapshot_settings @@ -20,6 +22,40 @@ "github.publish_as_check_run", ) +# A hunk whose new-file side covers lines 1-3, so a suggestion anchored at 1-1 +# passes the diff-hunk range validation in the summarized publish path. +_ANCHORED_HUNK_PATCH = ( + "@@ -1,3 +1,3 @@\n" + " line1\n" + "+line2-changed\n" + " line3\n" +) + + +def _anchored_suggestion(**overrides): + suggestion = { + "score": 1, + "relevant_file": "app.py", + "relevant_lines_start": 1, + "relevant_lines_end": 1, + "label": "maintainability", + "one_sentence_summary": "Anchored suggestion", + } + suggestion.update(overrides) + return suggestion + + +def _provider_with_anchored_diff(provider): + provider.diff_files = [ + FilePatchInfo( + base_file="line1\nline2\nline3\n", + head_file="line1\nline2-changed\nline3\n", + patch=_ANCHORED_HUNK_PATCH, + filename="app.py", + ) + ] + return provider + def _make_tool(provider): # A bare MagicMock returns a truthy mock here, which would thread every publish call. @@ -82,7 +118,7 @@ async def test_run_removes_progress_comment_when_cancelled( async def test_run_does_not_remove_final_summary_when_cancelled_during_dual_publishing(monkeypatch): settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) try: - provider = MagicMock() + provider = _provider_with_anchored_diff(MagicMock()) progress_comment = MagicMock(name="progress_comment") provider.get_files.return_value = [object()] provider.is_supported.return_value = True @@ -95,7 +131,7 @@ async def test_run_does_not_remove_final_summary_when_cancelled_during_dual_publ monkeypatch.setattr( pr_code_suggestions_module, "retry_with_fallback_models", - AsyncMock(return_value={"code_suggestions": [{"score": 1}]}), + AsyncMock(return_value={"code_suggestions": [_anchored_suggestion()]}), ) _configure_published_run() settings = get_settings() @@ -122,7 +158,7 @@ async def test_run_does_not_remove_final_summary_when_cancelled_during_dual_publ async def test_run_does_not_publish_failure_after_successful_summary(monkeypatch, persistent_comment): settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) try: - provider = MagicMock() + provider = _provider_with_anchored_diff(MagicMock()) progress_comment = MagicMock(name="progress_comment") provider.get_files.return_value = [object()] provider.is_supported.return_value = True @@ -135,7 +171,7 @@ async def test_run_does_not_publish_failure_after_successful_summary(monkeypatch monkeypatch.setattr( pr_code_suggestions_module, "retry_with_fallback_models", - AsyncMock(return_value={"code_suggestions": [{"score": 1}]}), + AsyncMock(return_value={"code_suggestions": [_anchored_suggestion()]}), ) _configure_published_run() settings = get_settings() @@ -233,7 +269,9 @@ async def test_run_publishes_failure_when_inline_suggestions_never_publish(monke monkeypatch.setattr( pr_code_suggestions_module, "retry_with_fallback_models", - AsyncMock(return_value={"code_suggestions": [{"score": 1}]}), + AsyncMock(return_value={"code_suggestions": [ + {"score": 1, "relevant_lines_start": 1, "relevant_lines_end": 1} + ]}), ) _configure_published_run() settings = get_settings() @@ -252,7 +290,7 @@ async def test_run_publishes_failure_when_inline_suggestions_never_publish(monke async def test_run_does_not_remove_persistent_summary_when_cancelled_during_dual_publishing(monkeypatch): settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) try: - provider = MagicMock() + provider = _provider_with_anchored_diff(MagicMock()) progress_comment = MagicMock(name="progress_comment") provider.get_files.return_value = [object()] provider.is_supported.return_value = True @@ -267,7 +305,7 @@ async def test_run_does_not_remove_persistent_summary_when_cancelled_during_dual monkeypatch.setattr( pr_code_suggestions_module, "retry_with_fallback_models", - AsyncMock(return_value={"code_suggestions": [{"score": 1}]}), + AsyncMock(return_value={"code_suggestions": [_anchored_suggestion()]}), ) _configure_published_run() settings = get_settings() @@ -322,7 +360,7 @@ async def test_run_preserves_cancellation_when_progress_cleanup_fails(monkeypatc async def test_run_cleans_up_progress_comment_on_check_run_publish(monkeypatch): settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) try: - provider = MagicMock() + provider = _provider_with_anchored_diff(MagicMock()) progress_comment = MagicMock(name="progress_comment") provider.get_files.return_value = [object()] provider.is_supported.return_value = True @@ -336,7 +374,7 @@ async def test_run_cleans_up_progress_comment_on_check_run_publish(monkeypatch): monkeypatch.setattr( pr_code_suggestions_module, "retry_with_fallback_models", - AsyncMock(return_value={"code_suggestions": [{"score": 1}]}), + AsyncMock(return_value={"code_suggestions": [_anchored_suggestion()]}), ) _configure_published_run() settings = get_settings() @@ -360,7 +398,7 @@ async def test_run_cleans_up_progress_comment_on_check_run_publish(monkeypatch): async def test_run_retains_progress_handle_when_check_run_cleanup_fails(monkeypatch): settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) try: - provider = MagicMock() + provider = _provider_with_anchored_diff(MagicMock()) progress_comment = MagicMock(name="progress_comment") provider.get_files.return_value = [object()] provider.is_supported.return_value = True @@ -375,7 +413,7 @@ async def test_run_retains_progress_handle_when_check_run_cleanup_fails(monkeypa monkeypatch.setattr( pr_code_suggestions_module, "retry_with_fallback_models", - AsyncMock(return_value={"code_suggestions": [{"score": 1}]}), + AsyncMock(return_value={"code_suggestions": [_anchored_suggestion()]}), ) _configure_published_run() settings = get_settings() @@ -515,3 +553,179 @@ async def test_failed_inline_retries_preserve_fallback_output( provider.remove_comment.assert_called_once_with(provider.publish_comment.return_value) finally: restore_settings(settings_snapshot) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("publish_output_no_suggestions", [True, False]) +async def test_run_routes_all_invalid_ranges_through_publish_no_suggestions( + monkeypatch, publish_output_no_suggestions +): + settings_snapshot = snapshot_settings( + _TRACKED_SETTINGS + ("pr_code_suggestions.publish_output_no_suggestions",) + ) + try: + provider = MagicMock() + provider.get_files.return_value = [object()] + provider.is_supported.return_value = True + provider.supports_code_suggestions_artifact.return_value = False + tool = _make_tool(provider) + tool.publish_no_suggestions = AsyncMock() + + monkeypatch.setattr( + pr_code_suggestions_module, "retry_with_fallback_models", + AsyncMock(return_value={"code_suggestions": [ + {"relevant_lines_start": -1, "relevant_lines_end": -1, "label": "bug", + "relevant_file": "app.py", "one_sentence_summary": "sentinel"}, + {"relevant_lines_start": 5, "relevant_lines_end": 2, "label": "bug", + "relevant_file": "app.py", "one_sentence_summary": "reversed"}, + ]}), + ) + _configure_published_run() + settings = get_settings() + settings.config.is_auto_command = True + settings.pr_code_suggestions.commitable_code_suggestions = False + settings.pr_code_suggestions.persistent_comment = False + settings.pr_code_suggestions.publish_output_no_suggestions = publish_output_no_suggestions + + await tool.run() + + tool.publish_no_suggestions.assert_awaited_once() + provider.publish_comment.assert_not_called() + assert tool._output_published is False + finally: + restore_settings(settings_snapshot) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("publish_output_no_suggestions", [True, False]) +async def test_run_all_invalid_ranges_honors_quiet_gate_via_real_publish( + monkeypatch, publish_output_no_suggestions +): + settings_snapshot = snapshot_settings( + _TRACKED_SETTINGS + ("pr_code_suggestions.publish_output_no_suggestions",) + ) + try: + provider = MagicMock() + provider.get_files.return_value = [object()] + provider.is_supported.return_value = True + provider.supports_code_suggestions_artifact.return_value = False + provider.publish_comment.return_value = MagicMock() + provider.should_publish_improve_as_thread.return_value = False + tool = _make_tool(provider) + tool.generate_summarized_suggestions = MagicMock(return_value="table") + + monkeypatch.setattr( + pr_code_suggestions_module, "retry_with_fallback_models", + AsyncMock(return_value={"code_suggestions": [ + {"relevant_lines_start": -1, "relevant_lines_end": -1, "label": "bug", + "relevant_file": "app.py", "one_sentence_summary": "sentinel"}, + ]}), + ) + _configure_published_run() + settings = get_settings() + settings.config.is_auto_command = True + settings.pr_code_suggestions.commitable_code_suggestions = False + settings.pr_code_suggestions.persistent_comment = False + settings.pr_code_suggestions.publish_output_no_suggestions = publish_output_no_suggestions + + await tool.run() + + tool.generate_summarized_suggestions.assert_not_called() + if publish_output_no_suggestions: + comments = [call.args[0] for call in provider.publish_comment.call_args_list] + assert len(comments) == 1 + assert "No code suggestions found for the PR." in comments[0] + else: + provider.publish_comment.assert_not_called() + assert get_settings().data["artifact"] == "" + finally: + restore_settings(settings_snapshot) + + +@pytest.mark.asyncio +async def test_run_plain_diff_leaks_no_progress_when_all_ranges_invalid(monkeypatch, capsys): + settings_snapshot = snapshot_settings( + _TRACKED_SETTINGS + ("pr_code_suggestions.publish_output_no_suggestions", "plain_diff.content") + ) + try: + get_settings().set( + "plain_diff.content", + "diff --git a/foo.py b/foo.py\n" + "index 1111111..2222222 100644\n" + "--- a/foo.py\n" + "+++ b/foo.py\n" + "@@ -1,3 +1,3 @@\n" + " line1\n" + "-line2\n" + "+line2-changed\n" + " line3\n", + ) + provider = PlainDiffGitProvider(None) + tool = PRCodeSuggestions.__new__(PRCodeSuggestions) + tool.git_provider = provider + tool.pr_url = "https://example.invalid/pull/1" + tool.progress_response = None + tool.incremental = SimpleNamespace(is_incremental=False) + tool.progress = "## Generating PR code suggestions" + + monkeypatch.setattr( + pr_code_suggestions_module, "retry_with_fallback_models", + AsyncMock(return_value={"code_suggestions": [ + {"relevant_lines_start": -1, "relevant_lines_end": -1, "label": "bug", + "relevant_file": "app.py", "one_sentence_summary": "sentinel"}, + ]}), + ) + _configure_published_run() + settings = get_settings() + settings.config.is_auto_command = False + settings.pr_code_suggestions.commitable_code_suggestions = False + settings.pr_code_suggestions.persistent_comment = False + + await tool.run() + + out = capsys.readouterr().out + # The output-only provider cannot edit/remove a previously published comment, so + # the progress placeholder must never be persisted and only the final document remains. + assert "Generating PR code suggestions" not in out + assert out.count("## PR Code Suggestions") == 1 + assert out.count("No code suggestions found for the PR.") == 1 + finally: + restore_settings(settings_snapshot) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("publish_no_suggestions", [True, False]) +async def test_run_valid_ranges_skip_no_suggestions_comment(monkeypatch, publish_no_suggestions): + settings_snapshot = snapshot_settings(_TRACKED_SETTINGS + ("pr_code_suggestions.publish_output_no_suggestions",)) + try: + provider = MagicMock() + provider.get_files.return_value = [object()] + provider.is_supported.return_value = True + provider.supports_code_suggestions_artifact.return_value = False + provider.publish_comment.return_value = MagicMock() + _provider_with_anchored_diff(provider) + tool = _make_tool(provider) + tool.publish_no_suggestions = AsyncMock() + + monkeypatch.setattr( + pr_code_suggestions_module, "retry_with_fallback_models", + AsyncMock(return_value={"code_suggestions": [ + {"relevant_lines_start": "2", "relevant_lines_end": "2", "label": "bug", + "relevant_file": "app.py", "one_sentence_summary": "valid", + "suggestion_content": "Content", "existing_code": "old()", + "improved_code": "new()", "score": 7}, + ]}), + ) + _configure_published_run() + settings = get_settings() + settings.config.is_auto_command = True + settings.pr_code_suggestions.commitable_code_suggestions = False + settings.pr_code_suggestions.persistent_comment = False + settings.pr_code_suggestions.publish_output_no_suggestions = publish_no_suggestions + + await tool.run() + + tool.publish_no_suggestions.assert_not_awaited() + assert tool._output_published is True + finally: + restore_settings(settings_snapshot) diff --git a/tests/unittest/test_pr_code_suggestions_pr_chat.py b/tests/unittest/test_pr_code_suggestions_pr_chat.py new file mode 100644 index 0000000000..866b3f6ef3 --- /dev/null +++ b/tests/unittest/test_pr_code_suggestions_pr_chat.py @@ -0,0 +1,153 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from pr_agent.algo.types import FilePatchInfo +from pr_agent.config_loader import get_settings +from pr_agent.git_providers.git_provider import GitProvider +from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.tools import pr_code_suggestions as pr_code_suggestions_module +from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +_TRACKED_SETTINGS = ( + "config.publish_output", + "config.publish_output_progress", + "config.is_auto_command", + "pr_code_suggestions.commitable_code_suggestions", + "pr_code_suggestions.demand_code_suggestions_self_review", + "pr_code_suggestions.enable_chat_text", + "pr_code_suggestions.enable_help_text", + "pr_code_suggestions.persistent_comment", + "pr_code_suggestions.dual_publishing_score_threshold", +) + + +class _CustomProvider(GitProvider): + """Minimal external provider that inherits the default chat capability.""" + + def is_supported(self, capability: str) -> bool: + return False + + def get_files(self) -> list: + return [] + + def get_diff_files(self) -> list: + return [] + + def publish_description(self, pr_title: str, pr_body: str): + pass + + def publish_code_suggestions(self, code_suggestions: list) -> bool: + return False + + def get_languages(self): + return {} + + def get_pr_branch(self): + return "" + + def get_user_id(self): + return "" + + def get_pr_description_full(self) -> str: + return "" + + def get_repo_settings(self): + return b"" + + def publish_comment(self, pr_comment: str, is_temporary: bool = False): + pass + + def publish_inline_comment( + self, body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None + ): + pass + + def publish_inline_comments(self, comments: list[dict]): + pass + + def remove_initial_comment(self): + pass + + def remove_comment(self, comment): + pass + + def get_issue_comments(self): + return [] + + def publish_labels(self, labels): + pass + + def get_pr_labels(self, update=False): + return [] + + def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: + return False + + def get_commit_messages(self) -> str: + return "" + + + +def test_git_provider_supports_pr_chat_defaults_to_false(): + assert _CustomProvider().supports_pr_chat() is False + + +def test_github_provider_supports_pr_chat_is_true(): + assert GithubProvider.supports_pr_chat(MagicMock()) is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("supports_pr_chat", "expect_link"), [(True, True), (False, False)]) +async def test_pr_chat_link_depends_on_provider_capability(monkeypatch, supports_pr_chat, expect_link): + snapshot = snapshot_settings(_TRACKED_SETTINGS) + try: + provider = MagicMock() + provider.get_files.return_value = ["file.py"] + provider.is_supported.side_effect = lambda capability: capability == "gfm_markdown" + provider.supports_pr_chat.return_value = supports_pr_chat + provider.should_publish_improve_as_thread.return_value = False + provider.diff_files = [ + FilePatchInfo( + base_file="old()\n", + head_file="new()\n", + patch="@@ -1,1 +1,1 @@\n-old()\n+new()\n", + filename="file.py", + ) + ] + + tool = PRCodeSuggestions.__new__(PRCodeSuggestions) + tool.pr_url = "https://example.invalid/pr/1" + tool.progress = "progress" + tool.progress_response = None + tool.git_provider = provider + tool.incremental = SimpleNamespace(is_incremental=False) + tool.generate_summarized_suggestions = MagicMock(return_value="## Suggestions") + + async def _fake_retry(*_args, **_kwargs): + return {"code_suggestions": [{"label": "style", "suggestion_content": "clean up", + "relevant_lines_start": 1, "relevant_lines_end": 1, + "relevant_file": "file.py"}]} + + monkeypatch.setattr(pr_code_suggestions_module, "retry_with_fallback_models", _fake_retry) + + settings = get_settings() + settings.config.publish_output = True + settings.config.publish_output_progress = False + settings.config.is_auto_command = True + settings.pr_code_suggestions.commitable_code_suggestions = False + settings.pr_code_suggestions.demand_code_suggestions_self_review = False + settings.pr_code_suggestions.enable_chat_text = True + settings.pr_code_suggestions.enable_help_text = False + settings.pr_code_suggestions.persistent_comment = False + settings.pr_code_suggestions.dual_publishing_score_threshold = 0 + + await tool.run() + + published_body = provider.publish_comment.call_args.args[0] + provider.supports_pr_chat.assert_called_once_with() + assert ("start a [PR chat]" in published_body) is expect_link + finally: + restore_settings(snapshot) diff --git a/tests/unittest/test_pr_code_suggestions_rendering.py b/tests/unittest/test_pr_code_suggestions_rendering.py index 9b0172831f..ac445f087d 100644 --- a/tests/unittest/test_pr_code_suggestions_rendering.py +++ b/tests/unittest/test_pr_code_suggestions_rendering.py @@ -36,6 +36,35 @@ def _suggestion(**overrides): return base +# A hunk whose new-file side covers lines 1-6 of the file. +DEFAULT_PATCH = ( + "@@ -1,5 +1,6 @@\n" + " def f():\n" + "- return old()\n" + "+ return new()\n" + " extra\n" + " more\n" + " lines\n" + "+ added line\n" +) + + +def _diff_file(filename="app.py", patch=DEFAULT_PATCH): + return FilePatchInfo( + base_file="def f():\n return old()\n extra\n more\n lines\n", + head_file="def f():\n return new()\n extra\n more\n lines\n added line\n", + patch=patch, + filename=filename, + ) + + +def _provider_with_diff_files(*filenames): + git_provider = MagicMock() + git_provider.diff_files = [_diff_file(filename) for filename in filenames] + git_provider.get_line_link.return_value = "" + return git_provider + + # --------------------------------------------------------------------------- # _truncate_if_needed # --------------------------------------------------------------------------- @@ -76,6 +105,110 @@ def test_truncate_if_needed_noop_when_under_limit_or_disabled(): restore_settings(snapshot) +@pytest.mark.parametrize( + "suggestion_kwargs", + [ + {"relevant_lines_start": None, "relevant_lines_end": 5}, + {"relevant_lines_start": -1, "relevant_lines_end": -1}, + {"relevant_lines_start": 0, "relevant_lines_end": 0}, + {"relevant_lines_start": -3, "relevant_lines_end": 1}, + {"relevant_lines_start": 5, "relevant_lines_end": 2}, + {"relevant_lines_start": "10", "relevant_lines_end": "bad"}, + {"relevant_lines_start": float("inf"), "relevant_lines_end": 5}, + {"relevant_lines_start": float("-inf"), "relevant_lines_end": 5}, + {"relevant_lines_start": 2, "relevant_lines_end": float("inf")}, + {"relevant_lines_start": float("nan"), "relevant_lines_end": 5}, + {"relevant_lines_start": 2, "relevant_lines_end": float("nan")}, + {"relevant_lines_start": 1.5, "relevant_lines_end": 5}, + {"relevant_lines_start": 2, "relevant_lines_end": 3.5}, + {"relevant_lines_start": True, "relevant_lines_end": True}, + ], +) +def test_is_suggestion_line_range_valid_rejects_unanchorable_ranges(suggestion_kwargs): + tool = _make_tool() + bad = _suggestion(**suggestion_kwargs) + + assert tool._is_suggestion_line_range_valid(bad) is False + + +def test_is_suggestion_line_range_valid_normalizes_valid_range(): + tool = _make_tool(_provider_with_diff_files("app.py")) + good = _suggestion(relevant_lines_start="2", relevant_lines_end="4") + + assert tool._is_suggestion_line_range_valid(good) is True + assert good["relevant_lines_start"] == 2 + assert good["relevant_lines_end"] == 4 + + +def test_is_suggestion_line_range_valid_rejects_missing_keys(): + tool = _make_tool() + suggestion = _suggestion() + suggestion.pop("relevant_lines_start") + suggestion.pop("relevant_lines_end") + + assert tool._is_suggestion_line_range_valid(suggestion) is False + + +def test_is_suggestion_line_range_valid_rejects_file_not_in_diff(): + tool = _make_tool(_provider_with_diff_files("app.py")) + suggestion = _suggestion(relevant_file="other.py") + + assert tool._is_suggestion_line_range_valid(suggestion) is False + + +def test_is_suggestion_line_range_valid_rejects_range_outside_diff(): + tool = _make_tool(_provider_with_diff_files("app.py")) + suggestion = _suggestion(relevant_lines_start=100, relevant_lines_end=100) + + assert tool._is_suggestion_line_range_valid(suggestion) is False + + +def test_is_suggestion_line_range_valid_follows_the_head_file_when_available(): + # Extended context outside the raw hunk is anchorable within the head file. + diff_file = _diff_file() + git_provider = MagicMock() + git_provider.diff_files = [diff_file] + tool = _make_tool(git_provider) + suggestion = _suggestion(relevant_lines_start=5, relevant_lines_end=9) + + assert tool._is_suggestion_line_range_valid(suggestion) is False + + diff_file.head_file += " trailing\n context\n lines\n" + + assert tool._is_suggestion_line_range_valid(suggestion) is True + + +@pytest.mark.parametrize("head_file", ["", "context\n" * 9]) +def test_is_suggestion_line_range_valid_uses_hunk_without_complete_head_file(head_file): + diff_file = _diff_file() + diff_file.head_file = head_file + diff_file.head_file_is_complete = False + git_provider = MagicMock() + git_provider.diff_files = [diff_file] + tool = _make_tool(git_provider) + + assert tool._is_suggestion_line_range_valid( + _suggestion(relevant_lines_start=2, relevant_lines_end=6)) is True + assert tool._is_suggestion_line_range_valid( + _suggestion(relevant_lines_start=5, relevant_lines_end=9)) is False + + +def test_get_patch_range_lines_rejects_oversized_span_without_enumerating_it(): + # A parseable but absurdly large span must be rejected by length comparison + # before any range(start, end) enumeration would iterate over it. + patch = DEFAULT_PATCH + + assert PRCodeSuggestions._get_patch_range_lines(patch, 1, 1_000_000_000) is None + assert PRCodeSuggestions._get_patch_range_lines(patch, 2, 6) == [ + " return new()", + " extra", + " more", + " lines", + " added line", + ] + assert PRCodeSuggestions._get_patch_range_lines(patch, 2, 2) == [" return new()"] + + def test_prepare_pr_code_suggestions_applies_truncation_inline(): settings = get_settings() snapshot = snapshot_settings(TRUNCATION_SETTINGS) @@ -307,7 +440,7 @@ def test_generate_summarized_suggestions_empty_returns_placeholder(): def test_generate_summarized_suggestions_renders_table_and_sorts_by_score(): - git_provider = MagicMock() + git_provider = _provider_with_diff_files("app.py", "auth.py") git_provider.get_line_link.return_value = "https://example.test/app.py#L2" tool = _make_tool(git_provider) settings = get_settings() @@ -343,8 +476,7 @@ def test_generate_summarized_suggestions_renders_table_and_sorts_by_score(): def test_generate_summarized_suggestions_uses_score_string_when_new_mechanism_enabled(): - git_provider = MagicMock() - git_provider.get_line_link.return_value = "" + git_provider = _provider_with_diff_files("app.py") tool = _make_tool(git_provider) settings = get_settings() snapshot = snapshot_settings(["pr_code_suggestions.new_score_mechanism"]) @@ -362,8 +494,7 @@ def test_generate_summarized_suggestions_uses_score_string_when_new_mechanism_en def test_generate_summarized_suggestions_escapes_angle_bracket_strings_in_summary(): - git_provider = MagicMock() - git_provider.get_line_link.return_value = "" + git_provider = _provider_with_diff_files("app.py") tool = _make_tool(git_provider) suggestion = _suggestion(one_sentence_summary="Replace '' with new_name") out = tool.generate_summarized_suggestions({"code_suggestions": [suggestion]}) @@ -375,8 +506,7 @@ def test_generate_summarized_suggestions_escapes_angle_bracket_strings_in_summar def test_generate_summarized_suggestions_includes_score_why_block_when_present(): - git_provider = MagicMock() - git_provider.get_line_link.return_value = "" + git_provider = _provider_with_diff_files("app.py") tool = _make_tool(git_provider) suggestion = _suggestion(score_why="Catches a real bug.") out = tool.generate_summarized_suggestions({"code_suggestions": [suggestion]}) @@ -384,6 +514,136 @@ def test_generate_summarized_suggestions_includes_score_why_block_when_present() assert "Why: Catches a real bug." in out +def test_generate_summarized_suggestions_skips_anchorless_but_keeps_rest(): + """A suggestion without resolved line anchors is skipped instead of failing the whole table.""" + git_provider = _provider_with_diff_files("app.py") + tool = _make_tool(git_provider) + anchored = _suggestion(one_sentence_summary="Keep me") + anchorless = _suggestion(one_sentence_summary="Drop me", relevant_file="other.py") + anchorless.pop("relevant_lines_start") + anchorless.pop("relevant_lines_end") + + out = tool.generate_summarized_suggestions({"code_suggestions": [anchorless, anchored]}) + + assert "" in out + assert "Keep me" in out + assert "Drop me" not in out + + +def test_generate_summarized_suggestions_skips_positive_range_outside_diff(): + """A positive, ordered range outside the file's diff hunks is dropped per-suggestion.""" + git_provider = _provider_with_diff_files("app.py") + tool = _make_tool(git_provider) + in_diff = _suggestion(one_sentence_summary="In diff") + out_diff = _suggestion( + one_sentence_summary="Hallucinated range", + relevant_lines_start=100, + relevant_lines_end=100, + ) + + out = tool.generate_summarized_suggestions({"code_suggestions": [in_diff, out_diff]}) + + assert "
      " in out + assert "In diff" in out + assert "Hallucinated range" not in out + + +def test_generate_summarized_suggestions_all_anchorless_returns_placeholder(): + """When no suggestion has resolvable anchors, publish a truthful message instead of an empty table.""" + git_provider = MagicMock() + git_provider.get_line_link.return_value = "" + tool = _make_tool(git_provider) + suggestion = _suggestion() + suggestion.pop("relevant_lines_start") + suggestion.pop("relevant_lines_end") + + out = tool.generate_summarized_suggestions({"code_suggestions": [suggestion]}) + + assert "No suggestions found to improve this PR." in out + assert "
      " not in out + + +@pytest.mark.parametrize( + "start,end", + [ + (-1, -1), # unresolved -1 sentinel written by self-reflection + (0, 0), + (-3, 1), + (5, 2), # reversed range + ], +) +def test_generate_summarized_suggestions_skips_invalid_line_ranges(start, end): + """Unresolved sentinels, non-positive lines and reversed ranges are omitted per-suggestion.""" + git_provider = _provider_with_diff_files("app.py") + tool = _make_tool(git_provider) + bad = _suggestion(one_sentence_summary="Bad range", relevant_lines_start=start, relevant_lines_end=end) + good = _suggestion(one_sentence_summary="Good range") + + out = tool.generate_summarized_suggestions({"code_suggestions": [bad, good]}) + + assert "
      " in out + assert "Good range" in out + assert "Bad range" not in out + + +@pytest.mark.parametrize("start,end", [(-1, -1), (0, 0), (5, 2)]) +def test_generate_summarized_suggestions_all_invalid_ranges_returns_placeholder(start, end): + git_provider = MagicMock() + git_provider.get_line_link.return_value = "" + tool = _make_tool(git_provider) + bad = _suggestion(relevant_lines_start=start, relevant_lines_end=end) + + out = tool.generate_summarized_suggestions({"code_suggestions": [bad]}) + + assert "No suggestions found to improve this PR." in out + assert "
      " not in out + + +@pytest.mark.asyncio +async def test_analyze_self_reflection_mismatched_count_does_not_crash(): + """Feedback covering a different suggestion count than generated leaves anchors unresolved.""" + tool = _make_tool() + suggestion = _suggestion() + suggestion.pop("relevant_lines_start") + suggestion.pop("relevant_lines_end") + data = {"code_suggestions": [suggestion, _suggestion(one_sentence_summary="Second")]} + + await tool.analyze_self_reflection_response( + data, "code_suggestions:\n- suggestion_score: 8\n why: fast\n" + ) + + assert "relevant_lines_start" not in data["code_suggestions"][0] + assert data["code_suggestions"][1]["relevant_lines_start"] == 2 + + +@pytest.mark.asyncio +async def test_analyze_self_reflection_null_feedback_does_not_crash(): + """A non-null response with `code_suggestions: null` must not abort suggestions.""" + tool = _make_tool() + suggestion = _suggestion() + suggestion.pop("relevant_lines_start") + suggestion.pop("relevant_lines_end") + data = {"code_suggestions": [suggestion]} + + await tool.analyze_self_reflection_response(data, "code_suggestions: null") + + assert "relevant_lines_start" not in data["code_suggestions"][0] + + +@pytest.mark.asyncio +async def test_analyze_self_reflection_non_mapping_feedback_does_not_crash(): + """A top-level list (not a mapping) from reflection must not abort suggestions.""" + tool = _make_tool() + suggestion = _suggestion() + suggestion.pop("relevant_lines_start") + suggestion.pop("relevant_lines_end") + data = {"code_suggestions": [suggestion]} + + await tool.analyze_self_reflection_response(data, "- one\n- two") + + assert "relevant_lines_start" not in data["code_suggestions"][0] + + # --------------------------------------------------------------------------- # Stale one-liner validation # --------------------------------------------------------------------------- diff --git a/tests/unittest/test_pr_command_profiles.py b/tests/unittest/test_pr_command_profiles.py index 1b01003897..e375ae350a 100644 --- a/tests/unittest/test_pr_command_profiles.py +++ b/tests/unittest/test_pr_command_profiles.py @@ -235,8 +235,16 @@ async def test_each_webhook_dispatches_its_default_profile(provider, monkeypatch assert agent.commands == DEFAULT_PR_COMMANDS[provider] -async def test_bitbucket_app_default_profile_passes_the_early_gate(monkeypatch): - calls = [] +async def _run_bitbucket_created_webhook( + monkeypatch, + *, + host_commands, + repo_commands, + eligibility=Eligibility.ELIGIBLE, + repo_should_process=True, + disable_auto_feedback=False, +): + trace = [] payload = { "event": "pullrequest:created", "data": { @@ -253,33 +261,234 @@ async def test_bitbucket_app_default_profile_passes_the_early_gate(monkeypatch): async def get_bearer_token(_shared_secret, _client_key): return "bearer" - async def perform_commands(*args): - calls.append(args) + class RecordingIdentityProvider: + def verify_eligibility(self, *_args): + trace.append("eligibility") + return eligibility + + def should_process_pr_logic(_data): + phase = "repo_filter" if "repo_settings" in trace else "host_filter" + trace.append(phase) + return repo_should_process if phase == "repo_filter" else True + + def apply_repo_settings(_url): + trace.append("repo_settings") + get_settings().set("BITBUCKET_APP.PR_COMMANDS", copy.deepcopy(repo_commands)) + get_settings().set("CONFIG.DISABLE_AUTO_FEEDBACK", disable_auto_feedback) + + async def run_commands(commands, *_args): + trace.append(("dispatch", commands)) - monkeypatch.setattr(server_utils, "get_settings", _Settings) monkeypatch.setattr(bitbucket_app, "is_bot_user", lambda _data: False) - monkeypatch.setattr(bitbucket_app, "should_process_pr_logic", lambda _data: True) + monkeypatch.setattr(bitbucket_app, "should_process_pr_logic", should_process_pr_logic) monkeypatch.setattr(bitbucket_app, "get_fork_safe_secret_provider", lambda: secret_provider) monkeypatch.setattr(bitbucket_app, "get_bearer_token", get_bearer_token) monkeypatch.setattr(bitbucket_app.jwt, "decode", lambda *args, **kwargs: {}) + monkeypatch.setattr(bitbucket_app, "get_identity_provider", RecordingIdentityProvider) + monkeypatch.setattr(bitbucket_app, "apply_repo_settings", apply_repo_settings) + monkeypatch.setattr(bitbucket_app, "_run_commands_bitbucket", run_commands) + endpoint = next(route.endpoint for route in bitbucket_app.router.routes if route.path == "/webhook") + background_tasks = BackgroundTasks() + original_bitbucket_app = copy.deepcopy(global_settings.bitbucket_app) + original_bitbucket = copy.deepcopy(global_settings.get("BITBUCKET", {})) + original_config = copy.deepcopy(global_settings.config) + global_settings.set("BITBUCKET_APP.PR_COMMANDS", copy.deepcopy(host_commands)) + global_settings.set("BITBUCKET.BASE_URL", "https://example.test/app") + global_settings.set("CONFIG.IS_AUTO_COMMAND", False) + + try: + with request_cycle_context({}): + result = await endpoint(background_tasks, _Request(payload)) + await background_tasks() + is_auto_command = get_settings().config.is_auto_command + finally: + global_settings.set("BITBUCKET_APP", original_bitbucket_app) + global_settings.set("BITBUCKET", original_bitbucket) + global_settings.set("CONFIG", original_config) + + return result, trace, is_auto_command + + +async def test_bitbucket_app_created_event_loads_repo_commands_before_dispatch(monkeypatch): + result, trace, is_auto_command = await _run_bitbucket_created_webhook( + monkeypatch, + host_commands=[], + repo_commands=["/review"], + ) + + assert result == "OK" + assert trace == [ + "host_filter", + "eligibility", + "repo_settings", + "repo_filter", + ("dispatch", ["/review"]), + ] + assert is_auto_command is True + + +async def test_bitbucket_app_created_event_skips_empty_repo_commands(monkeypatch): + result, trace, is_auto_command = await _run_bitbucket_created_webhook( + monkeypatch, + host_commands=["/review"], + repo_commands=[], + ) + + assert result == "OK" + assert trace == ["host_filter", "eligibility", "repo_settings", "repo_filter"] + assert is_auto_command is False + + +async def test_bitbucket_app_created_event_applies_repo_filter_before_dispatch(monkeypatch): + result, trace, is_auto_command = await _run_bitbucket_created_webhook( + monkeypatch, + host_commands=["/review"], + repo_commands=["/review"], + repo_should_process=False, + ) + + assert result == "OK" + assert trace == ["host_filter", "eligibility", "repo_settings", "repo_filter"] + assert is_auto_command is False + + +async def test_bitbucket_app_created_event_honors_repo_disable_auto_feedback(monkeypatch): + result, trace, is_auto_command = await _run_bitbucket_created_webhook( + monkeypatch, + host_commands=["/review"], + repo_commands=["/review"], + disable_auto_feedback=True, + ) + + assert result == "OK" + assert trace == ["host_filter", "eligibility", "repo_settings"] + assert is_auto_command is False + + +async def test_bitbucket_app_created_event_does_not_load_repo_settings_when_ineligible(monkeypatch): + result, trace, is_auto_command = await _run_bitbucket_created_webhook( + monkeypatch, + host_commands=[], + repo_commands=["/review"], + eligibility=Eligibility.NOT_ELIGIBLE, + ) + + assert result == "OK" + assert trace == ["host_filter", "eligibility"] + assert is_auto_command is False + + +async def _run_bitbucket_push_webhook( + monkeypatch, + *, + host_trigger, + host_commands, + repo_trigger, + repo_commands, +): + calls = [] + pr_url = "https://example.test/pr/1" + payload = { + "event": "pullrequest:updated", + "data": { + "actor": {"type": "user", "account_id": "account"}, + "pullrequest": {"links": {"html": {"href": pr_url}}}, + }, + } + secret_provider = type( + "SecretProvider", + (), + {"get_secret": lambda self, _key: json.dumps({"shared_secret": "secret"})}, + )() + + async def get_bearer_token(_shared_secret, _client_key): + return "bearer" + + def apply_repo_settings(_url): + calls.append("settings") + get_settings().set("BITBUCKET_APP.HANDLE_PUSH_TRIGGER", repo_trigger) + get_settings().set("BITBUCKET_APP.PUSH_COMMANDS", list(repo_commands)) + + async def validate_push(_data): + calls.append("validate") + return True + + @asynccontextmanager + async def record_slot(_key, **_kwargs): + calls.append("slot") + yield True + + async def run_commands(commands, *_args): + calls.append(("commands", list(commands))) + + monkeypatch.setattr(bitbucket_app, "is_bot_user", lambda _data: False) + monkeypatch.setattr(bitbucket_app, "get_fork_safe_secret_provider", lambda: secret_provider) + monkeypatch.setattr(bitbucket_app, "get_bearer_token", get_bearer_token) + monkeypatch.setattr(bitbucket_app.jwt, "decode", lambda *args, **kwargs: {}) + monkeypatch.setattr(bitbucket_app, "get_identity_provider", _IdentityProvider) + monkeypatch.setattr(bitbucket_app, "apply_repo_settings", apply_repo_settings) monkeypatch.setattr( bitbucket_app, - "get_identity_provider", - _IdentityProvider, + "should_process_pr_logic", + lambda _data: pytest.fail("Bitbucket update events must not use PR creation filters"), ) - monkeypatch.setattr(bitbucket_app, "_perform_commands_bitbucket", perform_commands) + monkeypatch.setattr(bitbucket_app, "_validate_time_from_last_commit_to_pr_update", validate_push) + monkeypatch.setattr(bitbucket_app, "push_trigger_slot", record_slot) + monkeypatch.setattr(bitbucket_app, "_run_commands_bitbucket", run_commands) endpoint = next(route.endpoint for route in bitbucket_app.router.routes if route.path == "/webhook") background_tasks = BackgroundTasks() + original_push_commands = global_settings.get("BITBUCKET_APP.PUSH_COMMANDS", None) + original_handle_push = global_settings.get("BITBUCKET_APP.HANDLE_PUSH_TRIGGER", None) + original_base_url = global_settings.get("BITBUCKET.BASE_URL", None) + global_settings.set("BITBUCKET_APP.HANDLE_PUSH_TRIGGER", host_trigger) + global_settings.set("BITBUCKET_APP.PUSH_COMMANDS", list(host_commands)) + global_settings.set("BITBUCKET.BASE_URL", "https://example.test/app") - with request_cycle_context({}): - context["settings"] = copy.deepcopy(global_settings) - get_settings().set("BITBUCKET.BASE_URL", "https://example.test/app") - result = await endpoint(background_tasks, _Request(payload)) - await background_tasks() + try: + with request_cycle_context({}): + result = await endpoint(background_tasks, _Request(payload)) + await background_tasks() + finally: + global_settings.set("BITBUCKET_APP.PUSH_COMMANDS", original_push_commands) + global_settings.set("BITBUCKET_APP.HANDLE_PUSH_TRIGGER", original_handle_push) + global_settings.set("BITBUCKET.BASE_URL", original_base_url) + + return result, calls + + +async def test_bitbucket_app_push_applies_repo_settings_before_effective_gate(monkeypatch): + result, calls = await _run_bitbucket_push_webhook( + monkeypatch, + host_trigger=False, + host_commands=[], + repo_trigger=True, + repo_commands=["/review"], + ) + + assert result == "OK" + assert calls == ["settings", "validate", "slot", ("commands", ["/review"])] + + +@pytest.mark.parametrize( + ("repo_trigger", "repo_commands"), + [ + (False, ["/review"]), + (True, []), + ], +) +async def test_bitbucket_app_effective_push_config_skips_before_validation( + monkeypatch, repo_trigger, repo_commands +): + result, calls = await _run_bitbucket_push_webhook( + monkeypatch, + host_trigger=True, + host_commands=["/host-review"], + repo_trigger=repo_trigger, + repo_commands=repo_commands, + ) assert result == "OK" - assert len(calls) == 1 - assert calls[0][0] == "pr_commands" + assert calls == ["settings"] @pytest.mark.parametrize("proceed", [True, False]) diff --git a/tests/unittest/test_pr_description.py b/tests/unittest/test_pr_description.py index fb2d2ae27a..401dc044df 100644 --- a/tests/unittest/test_pr_description.py +++ b/tests/unittest/test_pr_description.py @@ -1,11 +1,13 @@ +import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest import yaml from jinja2 import Environment, StrictUndefined +from pr_agent.algo.pr_processing import retry_with_fallback_models from pr_agent.algo.types import FilePatchInfo -from pr_agent.algo.utils import load_yaml +from pr_agent.algo.utils import ModelType, load_yaml from pr_agent.config_loader import get_settings from pr_agent.tools.pr_description import ( PRDescription, @@ -45,6 +47,7 @@ def _make_large_pr_instance(diff_files=None): FilePatchInfo("", "", "", "src/file1.py"), FilePatchInfo("", "", "", "src/file2.py"), ] + obj.ai_handler = MagicMock() obj.token_handler = MagicMock() obj.vars = { "title": "Test PR", @@ -68,6 +71,180 @@ def _make_large_pr_instance(diff_files=None): return obj +def _large_pr_chunks(): + chunks = [ + ["diff --git a/src/file1.py b/src/file1.py\n... file1 ..."], + ["diff --git a/src/file2.py b/src/file2.py\n... file2 ..."], + ] + return chunks, [10, 10], [], [], {}, [["src/file1.py"], ["src/file2.py"]] + + +def _file_prediction(filename: str) -> str: + return f"""pr_files: +- filename: {filename} + changes_title: Describe {filename} + changes_summary: File summary + label: enhancement""" + + +def _header_prediction() -> str: + return """type: +- Enhancement +title: Partial description +description: Summarizes the successfully described files.""" + + +@pytest.mark.asyncio +async def test_normal_description_rejects_clipped_packed_diff(): + tool = _make_large_pr_instance() + tool._description_prompt_handlers = {} + tool.ai_handler.chat_completion = AsyncMock() + + class ClippingBudget: + def fit_prompt_variable(self, _variables, _name, optional_text, **_kwargs): + return MagicMock( + optional_text=optional_text[:-1], + system_prompt="system", + user_prompt="user", + ) + + with patch( + "pr_agent.tools.pr_description.AttemptTokenBudget.for_prompt_attempt", + return_value=ClippingBudget(), + ): + with pytest.raises(ValueError, match="complete packed description diff"): + await tool._get_prediction("fallback-model", "complete-diff") + + tool.ai_handler.chat_completion.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_large_pr_header_uses_shared_budget_and_allows_walkthrough_truncation(): + tool = _make_large_pr_instance() + header_handler = MagicMock() + tool._description_prompt_handlers = { + "pr_description_only_description_prompts": header_handler, + } + tool.ai_handler.chat_completion = AsyncMock(return_value=(_header_prediction(), "stop")) + + fitted = MagicMock( + optional_text="trimmed walkthrough\n...(truncated)\n", + system_prompt="fitted system", + user_prompt="fitted user", + ) + budget = MagicMock() + budget.fit_prompt_variable.return_value = fitted + + with patch( + "pr_agent.tools.pr_description.AttemptTokenBudget.for_attempt", + return_value=budget, + ) as budget_factory: + result = await tool._get_prediction( + "fallback-model", + "complete walkthrough", + prompt="pr_description_only_description_prompts", + ) + + budget_factory.assert_called_once_with( + "fallback-model", + header_handler, + output_token_reserve=tool.ai_handler.get_output_token_reserve, + ) + budget.fit_prompt_variable.assert_called_once() + fit_args, fit_kwargs = budget.fit_prompt_variable.call_args + assert fit_args[1:] == ("diff", "complete walkthrough") + assert fit_kwargs == { + "ai_handler": tool.ai_handler, + "default_output_tokens": 1000, + "preserve_minimum": True, + } + tool.ai_handler.chat_completion.assert_awaited_once_with( + model="fallback-model", + temperature=get_settings().config.temperature, + system="fitted system", + user="fitted user", + ) + assert tool.variables["diff"] == fitted.optional_text + assert result == _header_prediction() + + +@pytest.mark.asyncio +async def test_large_pr_header_rejects_required_prompt_overflow_before_dispatch(): + tool = _make_large_pr_instance() + tool._description_prompt_handlers = { + "pr_description_only_description_prompts": MagicMock(), + } + tool.ai_handler.chat_completion = AsyncMock() + + budget = MagicMock() + budget.fit_prompt_variable.side_effect = ValueError( + "The required prompt exceeds the token limit for small-model" + ) + + with patch( + "pr_agent.tools.pr_description.AttemptTokenBudget.for_attempt", + return_value=budget, + ): + with pytest.raises(ValueError, match="required prompt exceeds"): + await tool._get_prediction( + "small-model", + "walkthrough", + prompt="pr_description_only_description_prompts", + ) + + tool.ai_handler.chat_completion.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_empty_primary_description_diff_uses_fallback_model(monkeypatch): + obj = _make_large_pr_instance() + obj._get_prediction = AsyncMock(return_value=_header_prediction()) + monkeypatch.setattr( + get_settings().pr_description, + "enable_large_pr_handling", + False, + ) + monkeypatch.setattr( + get_settings().pr_description, + "enable_semantic_files_types", + False, + ) + + def get_pr_diff(_provider, _handler, model, **_kwargs): + if model == "primary-model": + return "", ["src/too-large.py"] + return "fallback diff", [] + + with ( + patch("pr_agent.tools.pr_description.get_pr_diff", side_effect=get_pr_diff), + patch( + "pr_agent.tools.pr_description.fit_related_tickets_to_prompt_budget", + side_effect=lambda _pr, raw_vars, _system, _user, _model, **_kwargs: ( + raw_vars, + obj.token_handler, + ), + ), + patch( + "pr_agent.algo.pr_processing._get_all_models", + return_value=["primary-model", "fallback-model"], + ), + patch( + "pr_agent.algo.pr_processing._get_all_deployments", + return_value=[None, None], + ), + patch("pr_agent.algo.pr_processing.route_primary_model", return_value=None), + ): + await retry_with_fallback_models(obj._prepare_prediction, ModelType.WEAK) + + obj._get_prediction.assert_awaited_once_with( + "fallback-model", + "fallback diff", + prompt="pr_description_prompt", + ) + assert obj.patches_diff == "fallback diff" + assert obj.prediction == _header_prediction() + + def _mock_settings(pr_diagram_direction: str = 'adaptive', pr_diagram_direction_threshold: int = 5): """Mock get_settings used by _prepare_data.""" settings = MagicMock() @@ -276,14 +453,12 @@ def test_prepare_pr_answer_with_markers_replaces_plain_and_comment_markers(self, "changes_diagram": "\n```mermaid\ngraph LR\nA --> B\n```", } - title, body, walkthrough, file_changes = obj._prepare_pr_answer_with_markers() + title, body = obj._prepare_pr_answer_with_markers() assert title == "AI title" assert "Bug fix" in body assert "Fixes the cache invalidation bug." in body assert "```mermaid" in body - assert walkthrough == "" - assert file_changes == [] @pytest.mark.asyncio async def test_extend_uncovered_files_adds_missing_diff_files_to_prediction(self): @@ -553,9 +728,11 @@ async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt _, diff_kwargs = mock_diff.call_args assert diff_kwargs.get("large_pr_handling") is True assert diff_kwargs.get("return_remaining_files") is True + assert diff_kwargs.get("output_token_reserve") is obj.ai_handler.get_output_token_reserve # Verify get_pr_diff_multiple_patchs was invoked mock_multi.assert_called_once() + assert mock_multi.call_args.kwargs["output_token_reserve"] is obj.ai_handler.get_output_token_reserve # Verify calls to prompts prompts_called = [p for p, _ in recorded_prompts] @@ -583,6 +760,235 @@ async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt assert obj.data["title"].strip() == "Combined PR Title" assert [f["filename"].strip() for f in obj.data["pr_files"]] == ["src/file1.py", "src/file2.py"] + @pytest.mark.asyncio + @pytest.mark.parametrize("async_calls", [True, False]) + async def test_large_pr_retains_valid_chunk_when_sibling_raises(self, monkeypatch, async_calls): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", async_calls) + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + if prompt == "pr_description_only_description_prompts": + return _header_prediction() + if "file1" in patches_diff: + return _file_prediction("src/file1.py") + raise RuntimeError("file2 transport failed") + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=_large_pr_chunks(), + ): + await obj._prepare_prediction("gpt-4o") + + parsed = load_yaml(obj.prediction, keys_fix_yaml=obj.keys_fix) + assert [file["filename"].strip() for file in parsed["pr_files"]] == ["src/file1.py", "src/file2.py"] + assert parsed["pr_files"][1]["label"].strip() == "additional files" + assert obj.description_failed_chunk_count == 1 + assert obj.description_total_chunk_count == 2 + assert obj.description_failed_files == ["src/file2.py"] + + @pytest.mark.asyncio + @pytest.mark.parametrize("malformed_prediction", [ + "pr_files: []", + "pr_files:\n- not-a-file-record", + "pr_files:\n- {}", + """pr_files: +- filename: src/file2.py + changes_title: Missing label + changes_summary: File summary + label:""", + ]) + async def test_large_pr_retains_valid_chunk_when_sibling_is_malformed( + self, monkeypatch, malformed_prediction): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", True) + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + if prompt == "pr_description_only_description_prompts": + return _header_prediction() + if "file1" in patches_diff: + return _file_prediction("src/file1.py") + return malformed_prediction + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=_large_pr_chunks(), + ): + await obj._prepare_prediction("gpt-4o") + + parsed = load_yaml(obj.prediction, keys_fix_yaml=obj.keys_fix) + assert [file["filename"].strip() for file in parsed["pr_files"]] == ["src/file1.py", "src/file2.py"] + assert obj.description_failed_chunk_count == 1 + assert obj.description_failed_files == ["src/file2.py"] + + @pytest.mark.asyncio + async def test_large_pr_keeps_complete_records_from_mixed_chunk(self, monkeypatch): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", True) + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + if prompt == "pr_description_only_description_prompts": + return _header_prediction() + return """pr_files: +- filename: src/file1.py + changes_title: Describe src/file1.py + changes_summary: File summary + label: enhancement +- filename: src/file2.py + changes_title: Missing summary + label: enhancement""" + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=( + [["diff --git a/src/file1.py b/src/file1.py\n... file1 ...", + "diff --git a/src/file2.py b/src/file2.py\n... file2 ..."]], + [20], + [], + [], + {}, + [["src/file1.py", "src/file2.py"]], + ), + ): + await obj._prepare_prediction("gpt-4o") + + parsed = load_yaml(obj.prediction, keys_fix_yaml=obj.keys_fix) + assert [file["filename"].strip() for file in parsed["pr_files"]] == ["src/file1.py", "src/file2.py"] + obj._prepare_data() + assert obj._prepare_file_labels() == { + "enhancement": [("src/file1.py", "Describe src/file1.py", "File summary")] + } + + @pytest.mark.asyncio + @pytest.mark.parametrize("async_calls", [True, False]) + async def test_large_pr_all_failed_chunks_raise_for_model_fallback(self, monkeypatch, async_calls): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", async_calls) + chunk_calls = [] + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + chunk_calls.append(patches_diff) + raise RuntimeError(f"{patches_diff.splitlines()[0]} failed") + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=_large_pr_chunks(), + ), pytest.raises(RuntimeError, match="file1.py"): + await obj._prepare_prediction("gpt-4o") + + assert len(chunk_calls) == 2 + + @pytest.mark.asyncio + async def test_large_pr_all_malformed_chunks_raise_for_model_fallback(self, monkeypatch): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", True) + + obj._get_prediction = AsyncMock(return_value="pr_files:\n- not-a-file-record") + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=_large_pr_chunks(), + ), pytest.raises(ValueError, match="invalid YAML"): + await obj._prepare_prediction("gpt-4o") + + assert all( + call.kwargs["prompt"] == "pr_description_only_files_prompts" + for call in obj._get_prediction.await_args_list + ) + + @pytest.mark.asyncio + async def test_large_pr_empty_chunk_keeps_result_associated_with_its_files(self, monkeypatch): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", True) + chunks = [[], ["diff --git a/src/file2.py b/src/file2.py\n... file2 ..."]] + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + if prompt == "pr_description_only_description_prompts": + return _header_prediction() + return _file_prediction("src/file2.py") + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=(chunks, [0, 10], [], [], {}, [["src/file1.py"], ["src/file2.py"]]), + ): + await obj._prepare_prediction("gpt-4o") + + assert obj.description_failed_files == ["src/file1.py"] + assert obj.description_failed_chunk_count == 1 + assert len(obj._get_prediction.await_args_list) == 2 # one non-empty chunk and the header pass + + @pytest.mark.asyncio + async def test_large_pr_chunk_cancellation_propagates(self, monkeypatch): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", True) + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + if "file2" in patches_diff: + raise asyncio.CancelledError() + return _file_prediction("src/file1.py") + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=_large_pr_chunks(), + ), pytest.raises(asyncio.CancelledError): + await obj._prepare_prediction("gpt-4o") + + @pytest.mark.asyncio + async def test_large_pr_all_fail_then_fallback_model_keeps_partial_result(self, monkeypatch): + obj = _make_large_pr_instance() + monkeypatch.setattr(get_settings().pr_description, "async_ai_calls", True) + calls = [] + + async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt"): + calls.append((model, prompt, patches_diff)) + if model == "gpt-4o": + raise RuntimeError("primary chunk failed") + if prompt == "pr_description_only_description_prompts": + return _header_prediction() + if "file1" in patches_diff: + return _file_prediction("src/file1.py") + raise RuntimeError("fallback file2 failed") + + obj._get_prediction = AsyncMock(side_effect=mock_get_prediction) + with patch("pr_agent.tools.pr_description.get_pr_diff", return_value=""), patch( + "pr_agent.tools.pr_description.get_pr_diff_multiple_patchs", + return_value=_large_pr_chunks(), + ), patch( + "pr_agent.algo.pr_processing._get_all_models", return_value=["gpt-4o", "gpt-4o-mini"] + ), patch( + "pr_agent.algo.pr_processing._get_all_deployments", return_value=[None, None] + ), patch( + "pr_agent.algo.pr_processing.route_primary_model", return_value=None + ): + await retry_with_fallback_models(obj._prepare_prediction, ModelType.WEAK) + + assert [(model, prompt) for model, prompt, _ in calls] == [ + ("gpt-4o", "pr_description_only_files_prompts"), + ("gpt-4o", "pr_description_only_files_prompts"), + ("gpt-4o-mini", "pr_description_only_files_prompts"), + ("gpt-4o-mini", "pr_description_only_files_prompts"), + ("gpt-4o-mini", "pr_description_only_description_prompts"), + ] + assert obj.description_failed_chunk_count == 1 + assert obj.description_failed_files == ["src/file2.py"] + + def test_description_coverage_footer_names_failed_chunks_and_files(self): + obj = _make_large_pr_instance() + obj.description_failed_chunk_count = 1 + obj.description_total_chunk_count = 2 + obj.description_failed_files = ["src/file2.py"] + + footer = obj._get_description_coverage_footer() + + assert "1 of 2 file-description chunks failed" in footer + assert "based on the successful chunks only" in footer + assert "`src/file2.py`" in footer + assert "
      " not in footer + @pytest.mark.asyncio async def test_prepare_prediction_normal_diff_uses_single_prompt(self): """Verify normal diff under token limit does NOT fall into the large-PR multi-patch path.""" @@ -632,6 +1038,7 @@ async def mock_get_prediction(model, patches_diff, prompt="pr_description_prompt return """pr_files: - filename: src/file1.py changes_title: Add diagram support + changes_summary: Adds diagram support. label: enhancement""" elif prompt == "pr_description_only_description_prompts": return """type: diff --git a/tests/unittest/test_pr_description_help_rendering.py b/tests/unittest/test_pr_description_help_rendering.py new file mode 100644 index 0000000000..110459312f --- /dev/null +++ b/tests/unittest/test_pr_description_help_rendering.py @@ -0,0 +1,116 @@ +"""The /describe help footer picks list markup from provider capabilities.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from pr_agent.config_loader import get_settings +from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.git_providers.gitlab_provider import GitLabProvider +from pr_agent.tools import pr_description as pr_description_module +from pr_agent.tools.pr_description import PRDescription +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +_TRACKED_SETTINGS = ( + "config.is_auto_command", + "config.propagate_tool_errors", + "config.publish_output", + "pr_description.enable_help_comment", + "pr_description.enable_help_text", + "pr_description.enable_semantic_files_types", + "pr_description.final_update_message", + "pr_description.generate_ai_title", + "pr_description.publish_description_as_comment", + "pr_description.publish_labels", + "pr_description.use_description_markers", +) + +HTML_LIST_MARKER = "
    • Type /help how to ..." +PLAIN_BULLET_MARKER = "- Type /help how to ..." +PLAIN_BREAK_MARKER = "PR-Agent usage.
      - Check out the" + + +class StubProvider: + """A provider that is none of the concrete classes the tool used to branch on.""" + + def __init__(self, html_lists: bool): + self._html_lists = html_lists + self.html_lists_queries = 0 + self.descriptions = [] + + def is_supported(self, capability: str) -> bool: + return capability == "gfm_markdown" + + def supports_inline_help_footer(self) -> bool: + self.html_lists_queries += 1 + return self._html_lists + + def publish_description(self, title: str, body: str): + self.descriptions.append((title, body)) + + +@pytest.fixture +def configured_settings(): + snapshot = snapshot_settings(_TRACKED_SETTINGS) + settings = get_settings() + settings.config.is_auto_command = True + settings.config.propagate_tool_errors = True + settings.config.publish_output = True + settings.pr_description.enable_help_comment = True + settings.pr_description.enable_help_text = False + settings.pr_description.enable_semantic_files_types = False + settings.pr_description.final_update_message = False + settings.pr_description.generate_ai_title = True + settings.pr_description.publish_description_as_comment = False + settings.pr_description.publish_labels = False + settings.pr_description.use_description_markers = False + yield + restore_settings(snapshot) + + +async def render_description_help(provider, monkeypatch) -> str: + description = PRDescription.__new__(PRDescription) + description.pr_id = "1" + description.git_provider = provider + description.vars = {} + description.prediction = "generated" + description.data = None + description.file_label_dict = None + description._prepare_data = MagicMock() + description._prepare_pr_answer = MagicMock(return_value=("AI title", "Description", "")) + + monkeypatch.setattr(pr_description_module, "extract_and_cache_pr_tickets", AsyncMock()) + monkeypatch.setattr(pr_description_module, "retry_with_fallback_models", AsyncMock()) + + await description.run() + + assert len(provider.descriptions) == 1 + return provider.descriptions[0][1] + + +@pytest.mark.parametrize( + "html_lists, expected, unexpected", + [ + (True, HTML_LIST_MARKER, PLAIN_BULLET_MARKER), + (False, PLAIN_BULLET_MARKER, HTML_LIST_MARKER), + ], +) +async def test_description_help_rendering_follows_html_list_capability( + configured_settings, monkeypatch, html_lists, expected, unexpected +): + provider = StubProvider(html_lists) + + body = await render_description_help(provider, monkeypatch) + + assert provider.html_lists_queries == 1 + assert expected in body + assert unexpected not in body + assert "https://docs.pr-agent.ai/usage-guide/" in body + assert "qodo-merge-docs.qodo.ai" not in body + if not html_lists: + assert PLAIN_BREAK_MARKER in body + + +def test_providers_declare_html_list_capability(): + assert GithubProvider.__new__(GithubProvider).supports_inline_help_footer() is True + assert GitLabProvider.__new__(GitLabProvider).supports_inline_help_footer() is False diff --git a/tests/unittest/test_pr_description_lifecycle.py b/tests/unittest/test_pr_description_lifecycle.py index a00f0113ac..a2280f1116 100644 --- a/tests/unittest/test_pr_description_lifecycle.py +++ b/tests/unittest/test_pr_description_lifecycle.py @@ -148,6 +148,40 @@ async def test_run_neutralizes_progress_comment_before_delete(monkeypatch): restore_settings(settings_snapshot) +@pytest.mark.asyncio +async def test_run_appends_partial_description_coverage_to_output(monkeypatch): + settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) + try: + provider = MagicMock() + provider.is_supported.return_value = False + description = _make_description(provider) + description.prediction = "generated" + description.description_failed_chunk_count = 1 + description.description_total_chunk_count = 2 + description.description_failed_files = ["src/file2.py"] + description._prepare_data = MagicMock() + description._prepare_pr_answer = MagicMock(return_value=("AI title", "Description", "")) + + monkeypatch.setattr(pr_description_module, "extract_and_cache_pr_tickets", AsyncMock()) + monkeypatch.setattr(pr_description_module, "retry_with_fallback_models", AsyncMock()) + settings = get_settings() + settings.config.publish_output = False + settings.pr_description.enable_help_comment = False + settings.pr_description.enable_help_text = False + settings.pr_description.enable_semantic_files_types = False + settings.pr_description.publish_labels = False + settings.pr_description.use_description_markers = False + + await description.run() + + artifact = get_settings().data["artifact"] + assert "Description coverage" in artifact + assert "1 of 2 file-description chunks failed" in artifact + assert "`src/file2.py`" in artifact + finally: + restore_settings(settings_snapshot) + + @pytest.mark.asyncio @pytest.mark.parametrize("propagate_tool_errors", [False, True]) async def test_run_reports_description_publication_failure(monkeypatch, propagate_tool_errors): @@ -161,7 +195,7 @@ async def test_run_reports_description_publication_failure(monkeypatch, propagat description = _make_description(provider) description.prediction = "generated" description._prepare_data = MagicMock() - description._prepare_pr_answer = MagicMock(return_value=("AI title", "Description", "", [])) + description._prepare_pr_answer = MagicMock(return_value=("AI title", "Description", "")) monkeypatch.setattr(pr_description_module, "extract_and_cache_pr_tickets", AsyncMock()) monkeypatch.setattr(pr_description_module, "retry_with_fallback_models", AsyncMock()) @@ -185,7 +219,46 @@ async def test_run_reports_description_publication_failure(monkeypatch, propagat provider.publish_description.assert_called_once() assert call("Failed to update PR description") in provider.publish_comment.call_args_list - assert not any("updated to latest commit" in str(published) for published in provider.publish_comment.call_args_list) + assert not any( + "updated to latest commit" in str(published) for published in provider.publish_comment.call_args_list + ) provider.remove_comment.assert_called_once_with(progress_comment) finally: restore_settings(settings_snapshot) + + +@pytest.mark.asyncio +async def test_run_always_includes_walkthrough_in_the_description_body(monkeypatch): + # Regression guard for #3116: a provider advertising inline-comment capability support used + # to strip the File Walkthrough from the description body and publish nothing in its place. + # The walkthrough must always be appended on the non-marker rendering path. + settings_snapshot = snapshot_settings(_TRACKED_SETTINGS) + try: + provider = MagicMock() + provider.is_supported.return_value = True + description = _make_description(provider) + description.prediction = "generated" + description._prepare_data = MagicMock() + description._prepare_pr_answer = MagicMock(return_value=("AI title", "Base body", "WALKTHROUGH")) + + monkeypatch.setattr(pr_description_module, "extract_and_cache_pr_tickets", AsyncMock()) + monkeypatch.setattr(pr_description_module, "retry_with_fallback_models", AsyncMock()) + _configure_published_run() + settings = get_settings() + settings.pr_description.enable_help_comment = False + settings.pr_description.enable_help_text = False + settings.pr_description.enable_semantic_files_types = False + settings.pr_description.final_update_message = False + settings.pr_description.generate_ai_title = True + settings.pr_description.publish_description_as_comment = False + settings.pr_description.publish_labels = False + settings.pr_description.use_description_markers = False + + await description.run() + + provider.publish_description.assert_called_once() + title, body = provider.publish_description.call_args.args + assert title == "AI title" + assert "WALKTHROUGH" in body + finally: + restore_settings(settings_snapshot) diff --git a/tests/unittest/test_pr_description_output_core.py b/tests/unittest/test_pr_description_output_core.py index 9d7a6a6352..b848d224ca 100644 --- a/tests/unittest/test_pr_description_output_core.py +++ b/tests/unittest/test_pr_description_output_core.py @@ -25,10 +25,11 @@ import yaml from jinja2 import Environment, StrictUndefined, select_autoescape +from pr_agent.algo.comment_identity import PRDescriptionHeader from pr_agent.algo.types import FilePatchInfo -from pr_agent.algo.utils import PRDescriptionHeader, process_description +from pr_agent.algo.utils import process_description from pr_agent.config_loader import get_settings -from pr_agent.tools.pr_description import PRDescription +from pr_agent.tools.pr_description import PRDescription, sanitize_diagram KEYS_FIX = ["filename:", "language:", "changes_summary:", "changes_title:", "description:", "title:"] @@ -190,7 +191,7 @@ def test_html_comment_guard_prevents_type_replacement(self, mock_get_settings): body_in = "\npr_agent:type stays raw" obj = self._obj_with_user_description(body_in, {"title": "AI", "type": "Bug fix"}) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() # Guard present -> the plain marker is NOT replaced. assert "pr_agent:type stays raw" in body @@ -204,7 +205,7 @@ def test_plain_summary_marker_is_replaced(self, mock_get_settings): {"title": "AI", "description": "Adds caching layer."}, ) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() assert "Adds caching layer." in body assert "pr_agent:summary" not in body @@ -217,7 +218,7 @@ def test_summary_marker_is_removed_when_description_disabled(self, mock_get_sett {"title": "AI", "description": "Adds caching layer."}, ) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() assert "Adds caching layer." not in body assert "pr_agent:summary" not in body @@ -230,7 +231,7 @@ def test_html_comment_summary_marker_is_removed_when_description_disabled(self, {"title": "AI", "description": "Adds caching layer."}, ) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() assert "Adds caching layer." not in body assert "pr_agent:summary" not in body @@ -243,7 +244,7 @@ def test_generated_by_header_prefixes_replacements(self, mock_get_settings): {"title": "AI", "type": "Bug fix", "description": "Fix bug."}, ) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() assert "### 🤖 Generated by PR Agent at deadbeef" in body # Header appears for both replaced markers. @@ -257,7 +258,7 @@ def test_list_type_is_joined_with_comma(self, mock_get_settings): {"title": "AI", "type": ["Bug fix", "Refactor"]}, ) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() assert "Bug fix, Refactor" in body @@ -270,13 +271,41 @@ def test_diagram_marker_replaces_both_plain_and_html_comment(self, mock_get_sett {"title": "AI", "changes_diagram": diagram}, ) - _, body, _, _ = obj._prepare_pr_answer_with_markers() + _, body = obj._prepare_pr_answer_with_markers() # Both forms are substituted with the diagram. assert body.count("```mermaid") == 2 assert "" not in body assert "pr_agent:diagram" not in body.replace("```mermaid", "") + @pytest.mark.parametrize("marker", ["pr_agent:diagram", ""]) + @pytest.mark.parametrize("label", [r"C:\Users\demo", r"\d+", r"literal\ntext"]) + @patch("pr_agent.tools.pr_description.get_settings") + def test_diagram_marker_preserves_backslashes(self, mock_get_settings, label, marker): + mock_get_settings.return_value = _settings() + diagram = sanitize_diagram(f'```mermaid\nflowchart LR\nA["{label}"] --> B\n```') + obj = self._obj_with_user_description( + f"Before\n{marker}\nAfter", + {"title": "AI", "changes_diagram": diagram}, + ) + + _, body = obj._prepare_pr_answer_with_markers() + + assert body == f"Before\n{diagram}\nAfter" + + @patch("pr_agent.tools.pr_description.get_settings") + def test_empty_diagram_leaves_marker_unchanged(self, mock_get_settings): + mock_get_settings.return_value = _settings() + original_body = "Before\npr_agent:diagram\nAfter" + obj = self._obj_with_user_description( + original_body, + {"title": "AI", "changes_diagram": ""}, + ) + + _, body = obj._prepare_pr_answer_with_markers() + + assert body == original_body + @patch("pr_agent.tools.pr_description.get_settings") def test_title_falls_back_when_generate_ai_title_disabled(self, mock_get_settings): mock_get_settings.return_value = _settings(generate_ai_title=False) @@ -285,7 +314,7 @@ def test_title_falls_back_when_generate_ai_title_disabled(self, mock_get_setting {"title": "AI Title", "description": "x"}, ) - title, _, _, _ = obj._prepare_pr_answer_with_markers() + title, _ = obj._prepare_pr_answer_with_markers() assert title == "Original title" @@ -314,7 +343,7 @@ def test_labels_removed_when_provider_supports_get_labels(self, mock_get_setting obj = self._obj({"title": "t", "labels": ["bug"], "description": "d"}) obj.git_provider.is_supported.side_effect = lambda cap: cap in {"gfm_markdown", "get_labels"} - _, body, _, _ = obj._prepare_pr_answer() + _, body, _ = obj._prepare_pr_answer() # The Labels section is suppressed for providers with native label support. assert "Labels" not in body @@ -325,7 +354,7 @@ def test_type_section_removed_when_disabled(self, mock_get_settings): mock_get_settings.return_value = _settings(enable_pr_type=False) obj = self._obj({"title": "t", "type": "Bug fix", "description": "d"}) - _, body, _, _ = obj._prepare_pr_answer() + _, body, _ = obj._prepare_pr_answer() assert "PR Type" not in body assert "Bug fix" not in body @@ -335,7 +364,7 @@ def test_description_section_removed_when_disabled(self, mock_get_settings): mock_get_settings.return_value = _settings(enable_pr_description=False) obj = self._obj({"title": "t", "type": "Bug fix", "description": "AI summary"}) - _, body, _, _ = obj._prepare_pr_answer() + _, body, _ = obj._prepare_pr_answer() assert "AI summary" not in body # The other sections are untouched. @@ -353,7 +382,7 @@ def test_diagram_and_walkthrough_survive_a_disabled_description(self, mock_get_s "pr_files": [{"filename": "app.py", "changes_title": "c", "label": "enhancement"}], }) - _, body, walkthrough, _ = obj._prepare_pr_answer() + _, body, walkthrough = obj._prepare_pr_answer() assert "AI summary" not in body assert f"### {PRDescriptionHeader.DIAGRAM_WALKTHROUGH.value}" in body @@ -367,7 +396,7 @@ def test_description_kept_by_default(self, mock_get_settings): mock_get_settings.return_value = _settings() obj = self._obj({"title": "t", "description": "AI summary"}) - _, body, _, _ = obj._prepare_pr_answer() + _, body, _ = obj._prepare_pr_answer() assert "AI summary" in body @@ -379,7 +408,7 @@ def test_description_list_value_is_joined_and_bullets_spaced(self, mock_get_sett "description": "Intro\n- one\n- two", }) - _, body, _, _ = obj._prepare_pr_answer() + _, body, _ = obj._prepare_pr_answer() # Bullet readability: single newline before "-" becomes double newline. assert "Intro\n\n- one\n\n- two" in body @@ -390,7 +419,7 @@ def test_diagram_section_uses_header_enum(self, mock_get_settings): diagram = "\n```mermaid\ngraph LR\nA --> B\n```" obj = self._obj({"title": "t", "description": "d", "changes_diagram": diagram}) - _, body, _, _ = obj._prepare_pr_answer() + _, body, _ = obj._prepare_pr_answer() assert f"### {PRDescriptionHeader.DIAGRAM_WALKTHROUGH.value}" in body assert "```mermaid" in body @@ -400,7 +429,7 @@ def test_title_uses_vars_title_when_data_has_no_title(self, mock_get_settings): mock_get_settings.return_value = _settings(generate_ai_title=False) obj = self._obj({"description": "d"}) - title, _, _, _ = obj._prepare_pr_answer() + title, _, _ = obj._prepare_pr_answer() assert title == "Original title" @@ -423,10 +452,9 @@ def test_non_gfm_provider_skips_table_rendering(self, mock_get_settings): obj = self._obj(gfm=False) value = {"backend": [("src/app.py", "Add cache", "Adds a bounded cache.")]} - body, comments = obj.process_pr_files_prediction("PRE", value) + body = obj.process_pr_files_prediction("PRE", value) assert body == "PRE" - assert comments == [] @patch("pr_agent.tools.pr_description.get_settings") def test_gfm_provider_emits_table_with_file_row(self, mock_get_settings): @@ -437,14 +465,13 @@ def test_gfm_provider_emits_table_with_file_row(self, mock_get_settings): obj = self._obj(gfm=True, diff_files=[diff]) value = {"backend": [("src/app.py", "Add cache", "Adds a bounded cache.")]} - body, comments = obj.process_pr_files_prediction("", value) + body = obj.process_pr_files_prediction("", value) assert body.startswith("
    • ") assert body.rstrip().endswith("
      ") assert "Backend" in body assert "app.py" in body assert "+5/-2" in body - assert comments == [] @patch("pr_agent.tools.pr_description.get_settings") def test_adaptive_collapsible_triggers_above_threshold(self, mock_get_settings): @@ -458,7 +485,7 @@ def test_adaptive_collapsible_triggers_above_threshold(self, mock_get_settings): ] } - body, _ = obj.process_pr_files_prediction("", value) + body = obj.process_pr_files_prediction("", value) assert "
      2 files" in body @@ -480,7 +507,7 @@ def test_walkthrough_table_round_trips_through_process_description(self, mock_ge obj.git_provider.get_line_link.return_value = "https://example/blob/main/src/app.py#L1" value = {"backend": [("src/app.py", "Add cache", "Adds a bounded cache.")]} - table, _ = obj.process_pr_files_prediction("", value) + table = obj.process_pr_files_prediction("", value) full_description = ( "Some intro text.\n\n___\n\n" diff --git a/tests/unittest/test_pr_generate_labels.py b/tests/unittest/test_pr_generate_labels.py new file mode 100644 index 0000000000..16b3c54e17 --- /dev/null +++ b/tests/unittest/test_pr_generate_labels.py @@ -0,0 +1,66 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from pr_agent.tools.pr_generate_labels import PRGenerateLabels + + +def _tool(provider): + """``__init__`` builds a provider and a token handler, so wire up only what ``run`` reads.""" + tool = PRGenerateLabels.__new__(PRGenerateLabels) + tool.git_provider = provider + tool.pr_id = "owner/repo/1" + tool.prediction = "labels: []" + tool.data = {"labels": []} + tool._prepare_data = MagicMock() + tool._prepare_labels = MagicMock(return_value=["Bug fix"]) + return tool + + +def _provider(supports_labels): + provider = MagicMock() + provider.is_supported.side_effect = lambda capability: supports_labels + if not supports_labels: + # Gerrit and the local provider raise instead of returning an empty list. + provider.get_pr_labels.side_effect = NotImplementedError("Getting labels is not implemented") + else: + provider.get_pr_labels.return_value = [] + return provider + + +@pytest.mark.asyncio +@patch("pr_agent.tools.pr_generate_labels.retry_with_fallback_models", new_callable=AsyncMock) +@patch("pr_agent.tools.pr_generate_labels.get_settings") +async def test_labels_are_published_as_a_comment_when_the_provider_has_no_label_api( + mock_get_settings, _mock_retry): + """Regression for #2259: ``get_pr_labels`` was called before the ``get_labels`` capability + check, so ``/generate_labels`` died on Gerrit and the local provider instead of falling + back to the comment path.""" + mock_get_settings.return_value.config.publish_output = True + provider = _provider(supports_labels=False) + tool = _tool(provider) + + await tool.run() + + provider.get_pr_labels.assert_not_called() + provider.publish_labels.assert_not_called() + published = [call.args[0] for call in provider.publish_comment.call_args_list] + assert any("PR Labels" in body and "Bug fix" in body for body in published) + provider.remove_initial_comment.assert_called_once() + + +@pytest.mark.asyncio +@patch("pr_agent.tools.pr_generate_labels.retry_with_fallback_models", new_callable=AsyncMock) +@patch("pr_agent.tools.pr_generate_labels.get_settings") +async def test_user_labels_are_still_preserved_when_the_provider_supports_labels( + mock_get_settings, _mock_retry): + mock_get_settings.return_value.config.publish_output = True + provider = _provider(supports_labels=True) + provider.get_pr_labels.return_value = ["Review effort 3/5"] + tool = _tool(provider) + + with patch("pr_agent.tools.pr_generate_labels.get_user_labels", return_value=["Review effort 3/5"]): + await tool.run() + + provider.get_pr_labels.assert_called_once() + provider.publish_labels.assert_called_once_with(["Bug fix", "Review effort 3/5"]) diff --git a/tests/unittest/test_pr_help_docs_path_containment.py b/tests/unittest/test_pr_help_docs_path_containment.py new file mode 100644 index 0000000000..43007102c1 --- /dev/null +++ b/tests/unittest/test_pr_help_docs_path_containment.py @@ -0,0 +1,80 @@ +from types import SimpleNamespace + +from pr_agent.tools.pr_help_docs import PRHelpDocs + + +def _help_docs_with_clone(tmp_path, docs_path, include_root_readme_file=False): + clone_root = tmp_path / "clone" + clone_root.mkdir(parents=True, exist_ok=True) + (clone_root / "docs" / "guide.md").parent.mkdir(parents=True, exist_ok=True) + (clone_root / "docs" / "guide.md").write_text("docs content", encoding="utf-8") + provider = PRHelpDocs.__new__(PRHelpDocs) + provider.repo_url = "https://github.com/org/repo" + provider.ctx_url = "https://github.com/org/repo/pull/1" + provider.include_root_readme_file = include_root_readme_file + provider.supported_doc_exts = [".md"] + provider.docs_path = docs_path + provider.git_provider = SimpleNamespace(clone=lambda url, dst, remove_dest_folder: SimpleNamespace( + path=str(clone_root))) + return provider + + +def test_docs_path_within_clone_is_read(tmp_path): + provider = _help_docs_with_clone(tmp_path, "docs") + result = provider._gen_filenames_to_contents_map_from_repo() + assert result and "docs content" in next(iter(result.values())) + + +def test_absolute_docs_path_escaping_clone_is_rejected(tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.md").write_text("HOST SECRET CONTENT", encoding="utf-8") + provider = _help_docs_with_clone(tmp_path, str(outside)) + assert provider._gen_filenames_to_contents_map_from_repo() == {} + + +def test_traversing_docs_path_escaping_clone_is_rejected(tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.md").write_text("HOST SECRET CONTENT", encoding="utf-8") + provider = _help_docs_with_clone(tmp_path, "../outside") + assert provider._gen_filenames_to_contents_map_from_repo() == {} + + +def test_symlinked_doc_file_escaping_clone_is_skipped(tmp_path): + secret = tmp_path / "outside" / "secret.md" + secret.parent.mkdir(parents=True, exist_ok=True) + secret.write_text("HOST SECRET CONTENT", encoding="utf-8") + provider = _help_docs_with_clone(tmp_path, "docs") + (tmp_path / "clone" / "docs" / "leak.md").symlink_to(secret) + + result = provider._gen_filenames_to_contents_map_from_repo() + + assert any("docs content" in v for v in result.values()) + assert not any("HOST SECRET" in v for v in result.values()) + + +def test_symlinked_root_readme_escaping_clone_is_skipped(tmp_path): + secret = tmp_path / "outside" / "secret.md" + secret.parent.mkdir(parents=True, exist_ok=True) + secret.write_text("HOST SECRET CONTENT", encoding="utf-8") + provider = _help_docs_with_clone(tmp_path, "docs", include_root_readme_file=True) + clone_root = tmp_path / "clone" + (clone_root / "readme.txt").write_text("clone readme", encoding="utf-8") + (clone_root / "README.md").symlink_to(secret) + + result = provider._gen_filenames_to_contents_map_from_repo() + + assert any("clone readme" in v for v in result.values()) + assert not any("HOST SECRET" in v for v in result.values()) + + +def test_symlink_to_in_clone_file_is_kept(tmp_path): + provider = _help_docs_with_clone(tmp_path, "docs") + clone_root = tmp_path / "clone" + (clone_root / "docs" / "guide.md").write_text("original content", encoding="utf-8") + (clone_root / "docs" / "alias.md").symlink_to(clone_root / "docs" / "guide.md") + + result = provider._gen_filenames_to_contents_map_from_repo() + + assert sum("original content" in v for v in result.values()) >= 1 diff --git a/tests/unittest/test_pr_help_docs_row_isolation.py b/tests/unittest/test_pr_help_docs_row_isolation.py new file mode 100644 index 0000000000..4073382a76 --- /dev/null +++ b/tests/unittest/test_pr_help_docs_row_isolation.py @@ -0,0 +1,173 @@ +"""Regression tests for malformed model-produced /help_docs collections.""" + +import pytest + +from pr_agent.tools.pr_help_docs import ( + PRHelpDocs, + format_markdown_q_and_a_response, + get_valid_ranking_indices, + modify_answer_section, +) + + +class StubProvider: + def get_canonical_url_parts(self, **kwargs): + return "https://example.com/repo/blob/main", "" + + +def test_format_markdown_q_and_a_response_preserves_valid_sources_when_one_row_is_malformed(): + answer = format_markdown_q_and_a_response( + "Where is the guide?", + "The answer is here.", + [ + {"file_name": "/docs/guide.md", "relevant_section_header_string": "Getting started"}, + {"file_name": "/docs/broken.md"}, + ], + [".md"], + "https://example.com/repo/blob/main", + ) + + assert "The answer is here." in answer + assert "https://example.com/repo/blob/main/docs/guide.md#getting-started" in answer + assert "broken.md" not in answer + + +def test_format_markdown_q_and_a_response_omits_sources_when_all_rows_are_malformed(): + answer = format_markdown_q_and_a_response( + "Where is the guide?", + "The answer is here.", + [{"file_name": "/docs/broken.md"}, None, {"relevant_section_header_string": "Missing file"}], + [".md"], + "https://example.com/repo/blob/main", + ) + + assert answer == "### Question: \nWhere is the guide?\n\n### Answer:\nThe answer is here.\n\n" + assert "Relevant Sources" not in answer + + +def test_format_markdown_q_and_a_response_omits_malformed_source_collection(): + answer = format_markdown_q_and_a_response( + "Where is the guide?", + "The answer is here.", + {"file_name": "/docs/guide.md", "relevant_section_header_string": "Getting started"}, + [".md"], + "https://example.com/repo/blob/main", + ) + + assert answer == "### Question: \nWhere is the guide?\n\n### Answer:\nThe answer is here.\n\n" + + +def test_format_markdown_q_and_a_response_routes_unexpected_formatter_errors_to_failure_handler(monkeypatch): + def fail_to_format_header(header): + raise RuntimeError("unexpected formatter failure") + + monkeypatch.setattr("pr_agent.tools.pr_help_docs.format_markdown_header", fail_to_format_header) + + answer = format_markdown_q_and_a_response( + "Where is the guide?", + "The answer is here.", + [{"file_name": "/docs/guide.md", "relevant_section_header_string": "Getting started"}], + [".md"], + "https://example.com/repo/blob/main", + ) + + assert answer == "" + + +def test_modify_answer_section_preserves_answer_without_sources(): + assert modify_answer_section("### Answer:\nThe answer is here.\n\n") == ( + "### :bulb: Auto-generated documentation-based answer:\nThe answer is here.\n\n" + ) + + +def test_format_model_answer_preserves_answer_when_no_sources_survive(): + tool = PRHelpDocs.__new__(PRHelpDocs) + tool.question = "Where is the guide?" + tool.supported_doc_exts = [".md"] + tool.return_as_string = True + tool.repo_url = "https://example.com/org/repo" + tool.repo_url_given_explicitly = True + tool.repo_desired_branch = "main" + tool.git_provider = StubProvider() + + assert tool._format_model_answer("The answer is here.", [{"file_name": "/docs/broken.md"}]) == ( + "### :bulb: Auto-generated documentation-based answer:\nThe answer is here.\n\n" + ) + + +@pytest.mark.asyncio +async def test_run_preserves_answer_when_model_returns_empty_source_list(monkeypatch): + async def fake_retry_with_fallback_models(*args, **kwargs): + return """question_is_relevant: 1\nresponse: The answer is here.\nrelevant_sections: []\n""" + + monkeypatch.setattr("pr_agent.tools.pr_help_docs.retry_with_fallback_models", fake_retry_with_fallback_models) + + tool = PRHelpDocs.__new__(PRHelpDocs) + tool.question = "Where is the guide?" + tool.return_as_string = True + tool.repo_url = "https://example.com/org/repo" + tool.repo_url_given_explicitly = True + tool.repo_desired_branch = "main" + tool.supported_doc_exts = [".md"] + tool.git_provider = StubProvider() + tool.ai_handler = object() + tool.vars = {"question": tool.question, "snippets": ""} + tool._gen_filenames_to_contents_map_from_repo = lambda: {"/docs/guide.md": "# Guide\n\nGuide content."} + tool._trim_docs_input = lambda docs_input, *args, only_return_if_trim_needed=False, **kwargs: ( + False if only_return_if_trim_needed else docs_input + ) + + assert await tool.run() == ( + "### :bulb: Auto-generated documentation-based answer:\nThe answer is here.\n\n" + ) + + +def test_get_valid_ranking_indices_preserves_valid_order_and_skips_malformed_rows(): + assert get_valid_ranking_indices( + [ + {"idx": "2"}, + {"idx": True}, + {"idx": "not-a-number"}, + {"idx": 0}, + {"idx": 2}, + {"idx": 4}, + {"idx": 1.0}, + "not-a-row", + ], + 3, + ) == [2, 0, 2] + + +def test_get_valid_ranking_indices_skips_non_list_collections(): + assert get_valid_ranking_indices({"idx": 0}, 1) == [] + assert get_valid_ranking_indices(True, 1) == [] + + +def test_get_valid_ranking_indices_skips_all_malformed_rows(): + assert get_valid_ranking_indices( + [{"idx": "not-a-number"}, {"idx": -1}, {"idx": 2}, None], + 2, + ) == [] + + +@pytest.mark.asyncio +async def test_rank_docs_preserves_valid_rows_when_model_ranking_is_partially_malformed(monkeypatch): + async def fake_retry_with_fallback_models(*args, **kwargs): + return """relevant_files_ranking:\n - idx: 2\n - invalid: row\n - idx: 0\n""" + + monkeypatch.setattr("pr_agent.tools.pr_help_docs.retry_with_fallback_models", fake_retry_with_fallback_models) + + tool = PRHelpDocs.__new__(PRHelpDocs) + tool.ai_handler = object() + tool.vars = {"question": "Where is the guide?", "snippets": ""} + tool._trim_docs_input = lambda docs_input, *args, **kwargs: docs_input + docs = { + "/docs/first.md": "# First\n\nFirst document.", + "/docs/second.md": "# Second\n\nSecond document.", + "/docs/third.md": "# Third\n\nThird document.", + } + + ranked_prompt = await tool._rank_docs_and_return_them_as_prompt(docs, 10_000) + + assert "/docs/second.md" not in ranked_prompt + assert ranked_prompt.index("/docs/third.md") < ranked_prompt.index("/docs/first.md") diff --git a/tests/unittest/test_pr_help_message_fallback.py b/tests/unittest/test_pr_help_message_fallback.py new file mode 100644 index 0000000000..95647eb816 --- /dev/null +++ b/tests/unittest/test_pr_help_message_fallback.py @@ -0,0 +1,587 @@ +"""Question-mode help delegates failed attempts to the shared fallback loop.""" + +import asyncio +from math import ceil +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from pr_agent.algo import run_details +from pr_agent.config_loader import get_settings +from pr_agent.tools import pr_help_message +from pr_agent.tools.pr_help_message import PRHelpMessage +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +PRIMARY = "gpt-4o" +BACKUP = "gpt-4o-mini" +ANSWER = ( + "response: Enable automatic review in the repository settings.\n" + "relevant_sections:\n" + " - file_name: /tools/review.md\n" + " relevant_section_header_string: Automatic review\n" +) + + +@pytest.fixture +def help_tool(tmp_path, monkeypatch): + overrides = { + "config.model": PRIMARY, + "config.fallback_models": [BACKUP], + "config.publish_output": True, + "config.max_output_tokens": 0, + "config.model_token_count_estimate_factor": 0, + "model_routing.enable": False, + # Keep this regression independent of the separate OpenAI-key gate fix. + "openai.key": "test-only-key", + "openai.deployment_id": "primary-deployment", + "openai.fallback_deployments": ["backup-deployment"], + } + snapshot = snapshot_settings([*overrides, "pr_help_prompts.system", "pr_help_prompts.user"]) + for key, value in overrides.items(): + get_settings().set(key, value) + + doc = tmp_path / "docs" / "docs" / "tools" / "review.md" + doc.parent.mkdir(parents=True) + doc.write_text("# Automatic review\n\nEnable automatic review in the repository settings.", encoding="utf-8") + monkeypatch.setattr(pr_help_message, "__file__", str(tmp_path / "pr_agent" / "tools" / "pr_help_message.py")) + + details = run_details.RunDetails() + monkeypatch.setattr(run_details, "get_run_details", lambda: details) + logger = Mock() + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = SimpleNamespace(pr_url="https://example.com/org/repo/pull/1", publish_comment=Mock()) + tool.ai_handler = SimpleNamespace(chat_completion=AsyncMock()) + tool.question_str = "How do I configure automatic reviews?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + tool._fixture_doc = doc + try: + yield tool, details, logger + finally: + restore_settings(snapshot) + + +def attempted_models(tool): + return [call.kwargs["model"] for call in tool.ai_handler.chat_completion.await_args_list] + + +def count_message_characters(*, messages, **_kwargs): + return sum(len(message["content"]) for message in messages) + + +async def test_model_prompt_preserves_question_and_documentation_markup(help_tool): + tool, _, _ = help_tool + question = "What does ` & 'notes'` mean?" + snippets = "Use `a < b && c > d` and ``." + tool.vars.update(question=question, snippets=snippets) + tool.ai_handler.chat_completion.return_value = ANSWER, "stop" + + assert await tool._prepare_prediction(PRIMARY) == ANSWER + + tool.ai_handler.chat_completion.assert_awaited_once() + user_prompt = tool.ai_handler.chat_completion.await_args.kwargs["user"] + assert question in user_prompt + assert snippets in user_prompt + + +async def test_primary_failure_uses_backup_answer(help_tool): + tool, details, _ = help_tool + tool.ai_handler.chat_completion.side_effect = [RuntimeError("primary unavailable"), (ANSWER, "stop")] + + await tool.run() + + assert attempted_models(tool) == [PRIMARY, BACKUP] + backup_prompt = tool.ai_handler.chat_completion.await_args_list[1].kwargs["user"] + assert tool.question_str in backup_prompt + assert "Enable automatic review in the repository settings." in backup_prompt + tool.git_provider.publish_comment.assert_called_once() + published_comment = tool.git_provider.publish_comment.call_args.args[0] + assert "### Answer:\nEnable automatic review" in published_comment + assert "> - https://docs.pr-agent.ai/tools/review/#automatic-review" in published_comment + assert details.model_used == BACKUP + assert details.fallback_used is True + assert get_settings().get("openai.deployment_id") == "primary-deployment" + + +async def test_each_attempt_fits_complete_prompt_for_its_model(help_tool, monkeypatch): + tool, details, _ = help_tool + tool._fixture_doc.write_text("# Automatic review\n\n" + "A" * 600, encoding="utf-8") + get_settings().set("pr_help_prompts.system", "system") + get_settings().set("pr_help_prompts.user", "question={{ question }}\nsnippets={{ snippets }}") + get_settings().set("config.max_output_tokens", 20) + limits = {PRIMARY: 2_000, BACKUP: 260} + limit_calls = [] + counted_models = [] + + def model_limit(model, ignore_max_model_tokens=False): + limit_calls.append((model, ignore_max_model_tokens)) + return limits[model] + + def model_token_count(*, model, messages): + counted_models.append(model) + character_count = count_message_characters(messages=messages) + return character_count if model == PRIMARY else character_count * 2 + + monkeypatch.setattr(pr_help_message, "get_max_tokens", model_limit) + monkeypatch.setattr(pr_help_message, "token_counter", model_token_count) + tool.ai_handler.chat_completion.side_effect = [RuntimeError("primary unavailable"), (ANSWER, "stop")] + + await tool.run() + + assert attempted_models(tool) == [PRIMARY, BACKUP] + primary_prompt = tool.ai_handler.chat_completion.await_args_list[0].kwargs["user"] + backup_prompt = tool.ai_handler.chat_completion.await_args_list[1].kwargs["user"] + assert "A" * 600 in primary_prompt + assert "...(truncated)" not in primary_prompt + assert "A" * 600 not in backup_prompt + assert "...(truncated)" in backup_prompt + assert "==file name==" in backup_prompt + assert "/tools/review.md" in backup_prompt + assert 2 * (len(backup_prompt) + len("system")) <= limits[BACKUP] - 20 + assert "A" * 600 in tool.vars["snippets"] + assert limit_calls == [(PRIMARY, True), (BACKUP, True)] + assert PRIMARY in counted_models + assert BACKUP in counted_models + assert details.model_used == BACKUP + + +@pytest.mark.asyncio +async def test_fallback_only_publishes_sources_from_complete_fitted_documents(help_tool, monkeypatch): + tool, details, _ = help_tool + hidden_doc = tool._fixture_doc.parents[1] / "hidden.md" + hidden_doc.write_text("# Hidden\n\n" + "H" * 600, encoding="utf-8") + review_content = tool._fixture_doc.read_text(encoding="utf-8").strip() + review_section = ( + "==file name==\n\n/tools/review.md\n\n==file content==\n\n" + f"{review_content}\n=========" + ) + get_settings().set("pr_help_prompts.system", "") + get_settings().set("pr_help_prompts.user", "{{ snippets }}") + limits = { + PRIMARY: 10_000, + BACKUP: len(review_section) + len(pr_help_message.TRUNCATION_MARKER) + 1, + } + monkeypatch.setattr(tool, "_get_prompt_budget", lambda model: limits[model]) + monkeypatch.setattr(pr_help_message, "token_counter", count_message_characters) + tool.ai_handler.chat_completion.side_effect = [ + RuntimeError("primary unavailable"), + ( + "response: Use the documented review settings.\n" + "relevant_sections:\n" + " - file_name: /tools/review.md\n" + " relevant_section_header_string: Automatic review\n" + " - file_name: /hidden.md\n" + " relevant_section_header_string: Hidden\n", + "stop", + ), + ] + + await tool.run() + + assert attempted_models(tool) == [PRIMARY, BACKUP] + primary_prompt = tool.ai_handler.chat_completion.await_args_list[0].kwargs["user"] + backup_prompt = tool.ai_handler.chat_completion.await_args_list[1].kwargs["user"] + assert "/hidden.md" in primary_prompt + assert review_section in backup_prompt + assert "H" * 600 not in backup_prompt + assert pr_help_message.TRUNCATION_MARKER in backup_prompt + assert tool._model_visible_docs_files == {"tools/review.md"} + published_comment = tool.git_provider.publish_comment.call_args.args[0] + assert "> - https://docs.pr-agent.ai/tools/review/#automatic-review" in published_comment + assert "https://docs.pr-agent.ai/hidden/" not in published_comment + assert published_comment.count("> - ") == 1 + assert details.model_used == BACKUP + + +@pytest.mark.parametrize("separator_prefix", ["", "\n===="], ids=["before-separator", "inside-separator"]) +def test_fitted_docs_files_accepts_complete_content_without_full_separator(separator_prefix): + visible_document = ( + "==file name==\n\n/visible.md\n\n==file content==\n\n" + "# Visible\n\nAll visible content" + ) + hidden_document = ( + "==file name==\n\n/hidden.md\n\n==file content==\n\n" + "# Hidden\n\nHidden content" + ) + raw_snippets = f"{visible_document}\n=========\n\n\n{hidden_document}\n=========" + fitted_snippets = f"{visible_document}{separator_prefix}{pr_help_message.TRUNCATION_MARKER}" + + assert PRHelpMessage._get_fitted_docs_files( + raw_snippets, + fitted_snippets, + {"visible.md", "hidden.md"}, + ) == {"visible.md"} + + +def test_fitted_docs_files_rejects_document_without_framing_separator(): + document = ( + "==file name==\n\n/visible.md\n\n==file content==\n\n" + "# Visible\n\nAll visible content" + ) + + assert PRHelpMessage._get_fitted_docs_files( + document, + document, + {"visible.md"}, + ) == set() + + +def test_fitted_docs_files_uses_final_separator_after_delimiter_like_content(): + document = ( + "==file name==\n\n/visible.md\n\n==file content==\n\n" + "# Visible\n\nExample output:\n=========\nStill part of the document" + ) + raw_snippets = f"{document}\n=========" + fitted_snippets = f"{document}{pr_help_message.TRUNCATION_MARKER}" + + assert PRHelpMessage._get_fitted_docs_files( + raw_snippets, + fitted_snippets, + {"visible.md"}, + ) == {"visible.md"} + + +def test_fitted_docs_files_rejects_content_clipped_after_internal_delimiter(): + content_before_delimiter = ( + "==file name==\n\n/visible.md\n\n==file content==\n\n" + "# Visible\n\nExample output:\n=========" + ) + raw_snippets = f"{content_before_delimiter}\nStill part of the document\n=========" + fitted_snippets = f"{content_before_delimiter}{pr_help_message.TRUNCATION_MARKER}" + + assert PRHelpMessage._get_fitted_docs_files( + raw_snippets, + fitted_snippets, + {"visible.md"}, + ) == set() + + +@pytest.mark.parametrize( + ("configured_output", "expected_budget"), + [ + (400, 2_600), + ("400", 2_600), + (0, 1_000), + (-1, 1_000), + (None, 1_000), + ("invalid", 1_000), + (400.0, 2_600), + (400.5, 2_600), + (float("nan"), 1_000), + (float("inf"), 1_000), + (True, 2_999), + (False, 1_000), + ("1" * 5_000, 1_000), + ], +) +def test_prompt_budget_honors_positive_output_limit_or_help_default( + help_tool, monkeypatch, configured_output, expected_budget +): + tool, _, _ = help_tool + get_settings().set("config.max_output_tokens", configured_output) + monkeypatch.setattr(pr_help_message, "get_max_tokens", lambda *_args, **_kwargs: 3_000) + + assert tool._get_prompt_budget(PRIMARY) == expected_budget + + +def test_prompt_budget_uses_handler_reported_output_limit(help_tool, monkeypatch): + tool, _, _ = help_tool + get_settings().set("config.max_output_tokens", 400) + tool.ai_handler.get_output_token_limit = Mock(return_value=1_600) + monkeypatch.setattr(pr_help_message, "get_max_tokens", lambda *_args, **_kwargs: 3_000) + + assert tool._get_prompt_budget(PRIMARY) == 1_400 + tool.ai_handler.get_output_token_limit.assert_called_once_with(PRIMARY) + + +def test_prompt_budget_uses_handler_reported_output_reserve(help_tool, monkeypatch): + tool, _, _ = help_tool + get_settings().set("config.max_output_tokens", 400) + tool.ai_handler.get_output_token_reserve = Mock(return_value=1_600) + monkeypatch.setattr(pr_help_message, "get_max_tokens", lambda *_args, **_kwargs: 3_000) + + assert tool._get_prompt_budget(PRIMARY) == 1_400 + tool.ai_handler.get_output_token_reserve.assert_called_once_with(PRIMARY, 2_000) + + +@pytest.mark.parametrize("reported_limit", [None, 0, -1, True, 1.5, "1600"]) +def test_unusable_handler_output_limit_falls_back_to_config( + help_tool, monkeypatch, reported_limit +): + tool, _, _ = help_tool + get_settings().set("config.max_output_tokens", 400) + tool.ai_handler.get_output_token_limit = Mock(return_value=reported_limit) + monkeypatch.setattr(pr_help_message, "get_max_tokens", lambda *_args, **_kwargs: 3_000) + + assert tool._get_prompt_budget(PRIMARY) == 2_600 + + +def test_failing_handler_output_limit_falls_back_to_config(help_tool, monkeypatch): + tool, _, logger = help_tool + get_settings().set("config.max_output_tokens", 400) + tool.ai_handler.get_output_token_limit = Mock(side_effect=RuntimeError("unavailable")) + monkeypatch.setattr(pr_help_message, "get_max_tokens", lambda *_args, **_kwargs: 3_000) + + assert tool._get_prompt_budget(PRIMARY) == 2_600 + assert any("output token limit" in call.args[0] for call in logger.debug.call_args_list) + + +@pytest.mark.parametrize( + ("estimate_factor", "expected"), + [ + (-0.5, 53), + (0, 53), + (None, 53), + ("invalid", 53), + (float("nan"), 53), + (float("inf"), 53), + (True, 53), + (0.3, ceil(53 * 1.3)), + ], +) +def test_local_prompt_estimate_never_reduces_encoded_content( + help_tool, monkeypatch, estimate_factor, expected +): + tool, _, _ = help_tool + + class CharacterEncoder: + @staticmethod + def encode(text, disallowed_special=()): + return list(text) + + get_settings().set("config.model_token_count_estimate_factor", estimate_factor) + monkeypatch.setattr(pr_help_message, "token_counter", Mock(side_effect=RuntimeError("counter unavailable")), + raising=False) + monkeypatch.setattr(pr_help_message.TokenEncoder, "get_token_encoder", lambda _model: CharacterEncoder(), + raising=False) + + assert tool._count_prompt_tokens(PRIMARY, "abc", "de") == expected + + +def test_local_prompt_estimate_uses_raw_estimate_on_overflow(help_tool, monkeypatch): + tool, _, _ = help_tool + + class CharacterEncoder: + @staticmethod + def encode(text, disallowed_special=()): + return list(text) + + get_settings().set("config.model_token_count_estimate_factor", 1e308) + monkeypatch.setattr(pr_help_message, "token_counter", Mock(side_effect=RuntimeError("counter unavailable"))) + monkeypatch.setattr(pr_help_message.TokenEncoder, "get_token_encoder", lambda _model: CharacterEncoder()) + + assert tool._count_prompt_tokens(PRIMARY, "abc", "de") == 53 + + +@pytest.mark.parametrize("counter_mode", ["error", "zero", "boolean", "non_integer"]) +async def test_unusable_model_count_uses_local_estimate_before_provider_call( + help_tool, monkeypatch, counter_mode +): + tool, _, _ = help_tool + tool.vars = {"question": "q", "snippets": "A" * 100} + get_settings().set("pr_help_prompts.system", "") + get_settings().set("pr_help_prompts.user", "{{ snippets }}") + monkeypatch.setattr(tool, "_get_prompt_budget", lambda _model: 70) + + class CharacterEncoder: + @staticmethod + def encode(text, disallowed_special=()): + return list(text) + + def unusable_count(**_kwargs): + if counter_mode == "error": + raise RuntimeError("counter unavailable") + return {"zero": 0, "boolean": True, "non_integer": 1.5}[counter_mode] + + monkeypatch.setattr(pr_help_message, "token_counter", unusable_count) + monkeypatch.setattr(pr_help_message.TokenEncoder, "get_token_encoder", lambda _model: CharacterEncoder()) + tool.ai_handler.chat_completion.return_value = ANSWER, "stop" + + await tool._prepare_prediction(PRIMARY) + + tool.ai_handler.chat_completion.assert_awaited_once() + user_prompt = tool.ai_handler.chat_completion.await_args.kwargs["user"] + assert "...(truncated)" in user_prompt + assert len(user_prompt) + 48 <= 70 + + +async def test_local_estimate_overflow_still_tries_providers(help_tool, monkeypatch): + tool, details, _ = help_tool + + class CharacterEncoder: + @staticmethod + def encode(text, disallowed_special=()): + return list(text) + + get_settings().set("config.model_token_count_estimate_factor", 1e308) + monkeypatch.setattr(pr_help_message, "token_counter", Mock(side_effect=RuntimeError("counter unavailable"))) + monkeypatch.setattr(pr_help_message.TokenEncoder, "get_token_encoder", lambda _model: CharacterEncoder()) + tool._prepare_prediction = AsyncMock(wraps=tool._prepare_prediction) + tool.ai_handler.chat_completion.side_effect = [RuntimeError("primary unavailable"), (ANSWER, "stop")] + + await tool.run() + + assert [call.args[0] for call in tool._prepare_prediction.await_args_list] == [PRIMARY, BACKUP] + assert attempted_models(tool) == [PRIMARY, BACKUP] + tool.git_provider.publish_comment.assert_called_once() + assert details.model_used == BACKUP + + +async def test_empty_claude_system_prompt_is_normalized_before_fitting(help_tool, monkeypatch): + tool, _, _ = help_tool + model = "anthropic/claude-sonnet-4-5" + tool.vars = {"question": "q", "snippets": "A" * 100} + get_settings().set("pr_help_prompts.system", "") + get_settings().set("pr_help_prompts.user", "{{ snippets }}") + monkeypatch.setattr(tool, "_get_prompt_budget", lambda _model: 80) + tool.ai_handler.normalize_request_prompts = pr_help_message.LiteLLMAIHandler.normalize_request_prompts + counted_system_prompts = [] + + def count_normalized_prompts(*, messages, **_kwargs): + counted_system_prompts.append(messages[0]["content"]) + return count_message_characters(messages=messages) + + monkeypatch.setattr(pr_help_message, "token_counter", count_normalized_prompts) + tool.ai_handler.chat_completion.return_value = ANSWER, "stop" + + await tool._prepare_prediction(model) + + assert counted_system_prompts + assert set(counted_system_prompts) == {"No system prompt provided"} + call = tool.ai_handler.chat_completion.await_args + assert call.kwargs["system"] == "No system prompt provided" + assert "...(truncated)" in call.kwargs["user"] + + +def test_prompt_fitting_checks_smallest_nonempty_prefix(help_tool, monkeypatch): + tool, _, _ = help_tool + get_settings().set("pr_help_prompts.system", "") + get_settings().set("pr_help_prompts.user", "{{ snippets }}") + monkeypatch.setattr(tool, "_get_prompt_budget", lambda _model: 10) + + def discontinuous_count(*, messages, **_kwargs): + snippets = messages[1]["content"] + if snippets in ("", pr_help_message.TRUNCATION_MARKER): + return 1 + if snippets == f"A{pr_help_message.TRUNCATION_MARKER}": + return 10 + return 1_000 + + monkeypatch.setattr(pr_help_message, "token_counter", discontinuous_count) + + _, user_prompt, fitted_snippets = tool._fit_prompts({"question": "q", "snippets": "ABC"}, PRIMARY) + + assert user_prompt == f"A{pr_help_message.TRUNCATION_MARKER}" + assert fitted_snippets == f"A{pr_help_message.TRUNCATION_MARKER}" + + +async def test_marker_only_prompt_is_sent_when_no_document_character_fits(help_tool, monkeypatch): + tool, _, _ = help_tool + get_settings().set("pr_help_prompts.system", "") + get_settings().set("pr_help_prompts.user", "{{ snippets }}") + monkeypatch.setattr(tool, "_get_prompt_budget", lambda _model: 10) + + def marker_only_count(*, messages, **_kwargs): + snippets = messages[1]["content"] + if snippets == "": + return 1 + if snippets == pr_help_message.TRUNCATION_MARKER: + return 5 + return 1_000 + + monkeypatch.setattr(pr_help_message, "token_counter", marker_only_count) + tool.ai_handler.chat_completion.return_value = ANSWER, "stop" + + await tool.run() + + tool.ai_handler.chat_completion.assert_awaited_once() + assert tool.ai_handler.chat_completion.await_args.kwargs["user"] == pr_help_message.TRUNCATION_MARKER + assert tool._model_visible_docs_files == set() + published_comment = tool.git_provider.publish_comment.call_args.args[0] + assert "### Answer:\nEnable automatic review" in published_comment + assert "Relevant Sources" not in published_comment + assert "https://docs.pr-agent.ai" not in published_comment + + +async def test_fixed_prompt_overhead_skips_model_call_and_tries_larger_fallback(help_tool, monkeypatch): + tool, details, _ = help_tool + get_settings().set("pr_help_prompts.system", "S" * 300) + get_settings().set("pr_help_prompts.user", "question={{ question }}\nsnippets={{ snippets }}") + get_settings().set("config.max_output_tokens", 20) + limits = {PRIMARY: 250, BACKUP: 2_000} + + monkeypatch.setattr(pr_help_message, "get_max_tokens", + lambda model, ignore_max_model_tokens=False: limits[model]) + monkeypatch.setattr(pr_help_message, "token_counter", count_message_characters, raising=False) + tool._prepare_prediction = AsyncMock(wraps=tool._prepare_prediction) + tool.ai_handler.chat_completion.return_value = ANSWER, "stop" + + await tool.run() + + assert [call.args[0] for call in tool._prepare_prediction.await_args_list] == [PRIMARY, BACKUP] + assert attempted_models(tool) == [BACKUP] + assert details.model_used == BACKUP + + +async def test_all_models_fail_without_publishing_no_information(help_tool): + tool, details, logger = help_tool + tool.ai_handler.chat_completion.side_effect = RuntimeError("provider unavailable") + + assert await tool.run() == "" + + assert attempted_models(tool) == [PRIMARY, BACKUP] + tool.git_provider.publish_comment.assert_not_called() + logger.exception.assert_called_once() + assert "Failed to generate prediction with any model" in logger.exception.call_args.args[0] + assert details.model_used is None + assert get_settings().get("openai.deployment_id") == "primary-deployment" + + +@pytest.mark.parametrize( + "response, expected", + [(ANSWER, "### Answer:\nEnable automatic review"), + ("response: No matching information\nrelevant_sections: []", "Could not find relevant information")], +) +async def test_successful_primary_response_does_not_use_backup(help_tool, response, expected): + tool, details, _ = help_tool + tool.ai_handler.chat_completion.return_value = response, "stop" + + await tool.run() + + assert attempted_models(tool) == [PRIMARY] + tool.git_provider.publish_comment.assert_called_once() + assert expected in tool.git_provider.publish_comment.call_args.args[0] + assert details.model_used == PRIMARY + assert details.fallback_used is False + + +async def test_cancellation_propagates_without_backup(help_tool): + tool, details, logger = help_tool + tool.ai_handler.chat_completion.side_effect = asyncio.CancelledError() + + with pytest.raises(asyncio.CancelledError): + await tool.run() + + assert attempted_models(tool) == [PRIMARY] + tool.git_provider.publish_comment.assert_not_called() + logger.exception.assert_not_called() + assert details.model_used is None + assert get_settings().get("openai.deployment_id") == "primary-deployment" + + +async def test_invalid_prompt_reaches_final_failure_boundary(help_tool): + tool, details, logger = help_tool + get_settings().set("pr_help_prompts.user", "{{ missing_variable }}") + tool._prepare_prediction = AsyncMock(wraps=tool._prepare_prediction) + + assert await tool.run() == "" + + assert [call.args[0] for call in tool._prepare_prediction.await_args_list] == [PRIMARY, BACKUP] + tool.ai_handler.chat_completion.assert_not_called() + tool.git_provider.publish_comment.assert_not_called() + logger.exception.assert_called_once() + assert "Failed to generate prediction with any model" in logger.exception.call_args.args[0] + assert details.model_used is None + assert get_settings().get("openai.deployment_id") == "primary-deployment" diff --git a/tests/unittest/test_pr_help_message_rendering.py b/tests/unittest/test_pr_help_message_rendering.py new file mode 100644 index 0000000000..515288f609 --- /dev/null +++ b/tests/unittest/test_pr_help_message_rendering.py @@ -0,0 +1,325 @@ +"""Regression tests for provider-independent /help behavior.""" + +from pathlib import Path + +import pytest + +from pr_agent.config_loader import get_settings +from pr_agent.git_providers.bitbucket_provider import BitbucketProvider +from pr_agent.git_providers.bitbucket_server_provider import BitbucketServerProvider +from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.git_providers.gitlab_provider import GitLabProvider +from pr_agent.tools import pr_help_message as pr_help_message_module +from pr_agent.tools.pr_help_message import PRHelpMessage +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +INTERACTIVE_MARKER = "Trigger Interactively" +PLAIN_TABLE_MARKER = "| Tool | Description |" +UNSUPPORTED_MARKER = "requires gfm markdown" +CURRENT_DOCS_URL = "https://docs.pr-agent.ai" +RETIRED_DOCS_HOSTS = ("qodo-merge-docs.qodo.ai", "pr-agent-docs.codium.ai") + + +class StubProvider: + """A provider that is none of the concrete classes the tool used to branch on.""" + + def __init__(self, gfm_markdown: bool, markdown_tables: bool = False, checkbox_commands: bool = False): + self.pr_url = "https://example.com/org/repo/pull/1" + self._gfm_markdown = gfm_markdown + self._markdown_tables = markdown_tables + self._checkbox_commands = checkbox_commands + self.published = [] + + def is_supported(self, capability: str) -> bool: + return self._gfm_markdown if capability == "gfm_markdown" else True + + def supports_markdown_tables(self) -> bool: + return self._markdown_tables + + def supports_checkbox_commands(self) -> bool: + return self._checkbox_commands + + def publish_comment(self, pr_comment: str, is_temporary: bool = False): + self.published.append(pr_comment) + + +class StubAiHandler: + def __init__(self, response=None, error=None): + self.response = response + self.error = error + self.calls = [] + + async def chat_completion(self, *, model, temperature, system, user): + self.calls.append({"model": model, "system": system, "user": user}) + if self.error: + raise self.error + return self.response, "stop" + + +class StubTokenHandler: + @staticmethod + def count_tokens(text): + return len(text) + + +async def run_walkthrough(provider) -> str: + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = provider + tool.question_str = "" + tool.return_as_string = False + await tool.run() + assert len(provider.published) == 1 + return provider.published[0] + + +def build_question_tool(tmp_path, monkeypatch, handler): + package_root = tmp_path / "package" + docs_path = package_root / "docs" / "docs" + review_doc = docs_path / "tools" / "review.md" + review_doc.parent.mkdir(parents=True) + review_doc.write_text("# Automatic review\n\nEnable automatic review in the repository settings.", encoding="utf-8") + + source_path = package_root / "pr_agent" / "tools" / "pr_help_message.py" + monkeypatch.setattr(pr_help_message_module, "Path", lambda _: source_path) + + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = StubProvider(gfm_markdown=True) + tool.ai_handler = handler + tool.question_str = "How do I configure automatic reviews?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + tool.token_handler = StubTokenHandler() + return tool + + +@pytest.fixture +def published_output(): + snapshot = snapshot_settings(["config.publish_output", "config.disable_checkboxes"]) + get_settings().set("config.publish_output", True) + yield + restore_settings(snapshot) + + +@pytest.fixture +def non_openai_question_settings(): + keys = [ + "config.model", + "config.fallback_models", + "model_routing.enable", + "openai.key", + "openai.deployment_id", + ] + snapshot = snapshot_settings(keys) + settings = get_settings() + settings.set("config.model", "anthropic/claude-3-5-sonnet-20240620") + settings.set("config.fallback_models", []) + settings.set("model_routing.enable", False) + settings.set("openai.key", None) + settings.set("openai.deployment_id", None) + yield + restore_settings(snapshot) + + +async def test_question_reaches_configured_handler_without_openai_key( + published_output, non_openai_question_settings, tmp_path, monkeypatch +): + handler = StubAiHandler( + response=( + "response: Enable automatic review in the repository settings.\n" + "relevant_sections:\n" + " - file_name: /tools/review.md\n" + " relevant_section_header_string: Automatic review\n" + ) + ) + tool = build_question_tool(tmp_path, monkeypatch, handler) + + await tool.run() + + assert [call["model"] for call in handler.calls] == ["anthropic/claude-3-5-sonnet-20240620"] + assert tool.question_str in handler.calls[0]["user"] + assert "Enable automatic review in the repository settings." in handler.calls[0]["user"] + assert "Enable automatic review in the repository settings." in tool.git_provider.published[0] + assert "requires an OpenAI API key" not in tool.git_provider.published[0] + + +async def test_question_uses_configured_handler_error_path_without_openai_key( + published_output, non_openai_question_settings, tmp_path, monkeypatch +): + handler = StubAiHandler(error=RuntimeError("provider credentials missing")) + tool = build_question_tool(tmp_path, monkeypatch, handler) + + await tool.run() + + assert [call["model"] for call in handler.calls] == ["anthropic/claude-3-5-sonnet-20240620"] + assert tool.git_provider.published == [] + + +@pytest.mark.asyncio +async def test_question_skips_malformed_sources_without_discarding_answer( + published_output, non_openai_question_settings, tmp_path, monkeypatch +): + handler = StubAiHandler( + response=( + "response: Enable automatic review in the repository settings.\n" + "relevant_sections:\n" + " - file_name: /tools/review.md\n" + " relevant_section_header_string: Automatic review\n" + " - file_name: tools/review.md\n" + " relevant_section_header_string:\n" + " - relevant_section_header_string: Missing file name\n" + " - not a mapping\n" + " - file_name: ../../review.md\n" + " relevant_section_header_string: Automatic review\n" + " - file_name: https://example.com/review.md\n" + " relevant_section_header_string: Automatic review\n" + " - file_name: /tools/review.md?draft=true\n" + " relevant_section_header_string: Automatic review\n" + " - file_name: ' /tools/review.md '\n" + " relevant_section_header_string: Automatic review\n" + ) + ) + tool = build_question_tool(tmp_path, monkeypatch, handler) + + await tool.run() + + assert len(tool.git_provider.published) == 1 + comment = tool.git_provider.published[0] + assert "Enable automatic review in the repository settings." in comment + assert f"> - {CURRENT_DOCS_URL}/tools/review/#automatic-review\n" in comment + assert f"> - {CURRENT_DOCS_URL}/tools/review/\n" in comment + assert comment.count("> - ") == 2 + assert "example.com" not in comment + assert "draft=true" not in comment + + +@pytest.mark.parametrize( + "relevant_sections", + [ + " - relevant_section_header_string: Missing file name\n", + " file_name: /tools/review.md\n relevant_section_header_string: Automatic review\n", + ], + ids=["all-invalid-rows", "non-list-value"], +) +@pytest.mark.asyncio +async def test_question_preserves_answer_when_no_valid_sources_remain( + published_output, non_openai_question_settings, tmp_path, monkeypatch, relevant_sections +): + handler = StubAiHandler( + response=( + "response: Enable automatic review in the repository settings.\n" + "relevant_sections:\n" + f"{relevant_sections}" + ) + ) + tool = build_question_tool(tmp_path, monkeypatch, handler) + + await tool.run() + + assert len(tool.git_provider.published) == 1 + comment = tool.git_provider.published[0] + assert "Enable automatic review in the repository settings." in comment + assert "Relevant Sources" not in comment + assert CURRENT_DOCS_URL not in comment + + +@pytest.mark.asyncio +async def test_question_does_not_link_to_document_that_failed_to_load( + published_output, non_openai_question_settings, tmp_path, monkeypatch +): + unreadable_doc = tmp_path / "package" / "docs" / "docs" / "faq.md" + handler = StubAiHandler( + response=( + "response: Enable automatic review in the repository settings.\n" + "relevant_sections:\n" + " - file_name: /faq.md\n" + " relevant_section_header_string: FAQ\n" + ) + ) + tool = build_question_tool(tmp_path, monkeypatch, handler) + unreadable_doc.write_text("# FAQ", encoding="utf-8") + real_read_text = Path.read_text + + def fail_unreadable_doc(document, *args, **kwargs): + if document == unreadable_doc: + raise OSError("unreadable test document") + return real_read_text(document, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", fail_unreadable_doc) + + await tool.run() + + assert len(tool.git_provider.published) == 1 + comment = tool.git_provider.published[0] + assert "Enable automatic review in the repository settings." in comment + assert "Relevant Sources" not in comment + assert f"{CURRENT_DOCS_URL}/faq/" not in comment + + +@pytest.mark.parametrize( + "capabilities, expected, unexpected", + [ + # gfm markdown plus checkbox commands is the only combination that offers the checkboxes + (dict(gfm_markdown=True, checkbox_commands=True), INTERACTIVE_MARKER, PLAIN_TABLE_MARKER), + (dict(gfm_markdown=True), "", INTERACTIVE_MARKER), + # without gfm markdown, a provider that renders pipe tables gets the basic table + (dict(gfm_markdown=False, markdown_tables=True), PLAIN_TABLE_MARKER, "
      "), + (dict(gfm_markdown=False), UNSUPPORTED_MARKER, "
      "), + ], +) +async def test_rendering_follows_capabilities(published_output, capabilities, expected, unexpected): + comment = await run_walkthrough(StubProvider(**capabilities)) + assert expected in comment + assert unexpected not in comment + + +async def test_checkboxes_stay_disabled_by_configuration(published_output): + get_settings().set("config.disable_checkboxes", True) + comment = await run_walkthrough(StubProvider(gfm_markdown=True, checkbox_commands=True)) + assert INTERACTIVE_MARKER not in comment + assert "
      " in comment + + +@pytest.mark.parametrize("checkbox_commands", [False, True], ids=["without-checkboxes", "with-checkboxes"]) +async def test_walkthrough_uses_current_documentation_site(published_output, checkbox_commands): + comment = await run_walkthrough(StubProvider(gfm_markdown=True, checkbox_commands=checkbox_commands)) + + assert (INTERACTIVE_MARKER in comment) is checkbox_commands + assert f"{CURRENT_DOCS_URL}/tools/review/" in comment + assert f"{CURRENT_DOCS_URL}/usage-guide/automations_and_usage/" in comment + assert not any(host in comment for host in RETIRED_DOCS_HOSTS) + + +@pytest.mark.parametrize( + ("file_name", "header", "expected"), + [ + ("/index.md", "", f"{CURRENT_DOCS_URL}/"), + ("/tools/review.md", "Automatic review", f"{CURRENT_DOCS_URL}/tools/review/#automatic-review"), + ("/faq/index.md", "Frequently asked questions", f"{CURRENT_DOCS_URL}/faq/#frequently-asked-questions"), + ], +) +def test_question_source_urls_use_canonical_documentation_paths(file_name, header, expected): + tool = PRHelpMessage.__new__(PRHelpMessage) + + assert tool.format_docs_url(file_name, header) == expected + + +def test_question_system_prompt_identifies_the_open_source_pr_agent_project(): + system_prompt, _ = PRHelpMessage._render_prompts({"question": "What is PR-Agent?", "snippets": ""}) + + assert "the open-source PR-Agent project" in system_prompt + assert "recently renamed" not in system_prompt.lower() + + +@pytest.mark.parametrize( + "provider_class, checkbox_commands, markdown_tables", + [ + (GithubProvider, True, False), + (GitLabProvider, False, False), + (BitbucketProvider, False, False), + (BitbucketServerProvider, False, True), + ], +) +def test_providers_declare_the_capabilities_the_tool_reads(provider_class, checkbox_commands, markdown_tables): + provider = provider_class.__new__(provider_class) + assert provider.supports_checkbox_commands() is checkbox_commands + assert provider.supports_markdown_tables() is markdown_tables diff --git a/tests/unittest/test_pr_help_message_resources.py b/tests/unittest/test_pr_help_message_resources.py new file mode 100644 index 0000000000..94b38e6738 --- /dev/null +++ b/tests/unittest/test_pr_help_message_resources.py @@ -0,0 +1,383 @@ +"""Regression tests for the question-mode /help documentation corpus.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from pr_agent.tools import pr_help_message +from pr_agent.tools.pr_help_message import HELP_DOCS_UNAVAILABLE_MESSAGE, PRHelpMessage + + +def _write_document(root: Path, relative_path: str, content: str) -> Path: + document = root / relative_path + document.parent.mkdir(parents=True, exist_ok=True) + document.write_text(content, encoding="utf-8") + return document + + +@pytest.mark.parametrize( + ("relative_path", "expected"), + [ + ("tools/review.md", True), + ("tools/review.txt", False), + ("core-abilities/compression_strategy.md", False), + ("finetuning_benchmark/sample.md", False), + ("nested/finetuning_benchmark/sample.md", False), + ], +) +def test_help_doc_inclusion_predicate(relative_path, expected): + assert pr_help_message._is_help_doc_included(pr_help_message.PurePosixPath(relative_path)) is expected + + +def test_packaged_help_docs_are_filtered_and_priority_sorted(tmp_path, monkeypatch): + package_root = tmp_path / "pr_agent" + docs_root = package_root / "_help_docs" + _write_document(docs_root, "index.md", "root") + _write_document(docs_root, "tools/review.md", "review") + _write_document(docs_root, "reference/other.md", "other") + _write_document(docs_root, "core-abilities/compression_strategy.md", "excluded") + _write_document(docs_root, "finetuning_benchmark/sample.md", "excluded") + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + + prompt, available_docs_files = pr_help_message._load_help_docs_prompt() + + assert prompt.index("/index.md") < prompt.index("/tools/review.md") < prompt.index("/reference/other.md") + assert "compression_strategy" not in prompt + assert "finetuning_benchmark" not in prompt + assert available_docs_files == {"index.md", "tools/review.md", "reference/other.md"} + + +def test_source_checkout_is_used_when_packaged_docs_are_absent(tmp_path, monkeypatch): + package_root = tmp_path / "installed" / "pr_agent" + package_root.mkdir(parents=True) + source_module = tmp_path / "checkout" / "pr_agent" / "tools" / "pr_help_message.py" + _write_document(tmp_path / "checkout" / "docs" / "docs", "tools/review.md", "source checkout") + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + monkeypatch.setattr(pr_help_message, "__file__", str(source_module)) + + prompt, available_docs_files = pr_help_message._load_help_docs_prompt() + + assert "/tools/review.md" in prompt + assert "source checkout" in prompt + assert available_docs_files == {"tools/review.md"} + + +def test_source_fallback_preserves_packaged_resource_traceback(tmp_path, monkeypatch): + source_module = tmp_path / "checkout" / "pr_agent" / "tools" / "pr_help_message.py" + source_docs = tmp_path / "checkout" / "docs" / "docs" + _write_document(source_docs, "tools/review.md", "source checkout") + logger = Mock() + + def fail_package_lookup(_package): + raise OSError("broken resource provider") + + monkeypatch.setattr(pr_help_message, "package_files", fail_package_lookup) + monkeypatch.setattr(pr_help_message, "__file__", str(source_module)) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + + assert pr_help_message._get_help_docs_root() == source_docs + logger.opt.assert_called_once_with(exception=True) + logger.opt.return_value.debug.assert_called_once_with( + "Unable to inspect packaged /help documentation" + ) + + +def test_traversal_failures_preserve_tracebacks(tmp_path, monkeypatch): + docs_root = tmp_path / "docs" + broken_document = _write_document(docs_root, "broken.md", "unreadable metadata") + original_is_dir = Path.is_dir + logger = Mock() + + def is_dir(path): + if path == broken_document: + raise OSError("broken stat") + return original_is_dir(path) + + monkeypatch.setattr(Path, "is_dir", is_dir) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + + assert pr_help_message._iter_help_docs(docs_root) == [] + logger.opt.assert_called_once_with(exception=True) + logger.opt.return_value.error.assert_called_once_with( + f"Error while inspecting the documentation path {broken_document}" + ) + + +def test_directory_listing_failure_preserves_traceback(tmp_path, monkeypatch): + docs_root = tmp_path / "docs" + docs_root.mkdir() + original_iterdir = Path.iterdir + logger = Mock() + + def iterdir(path): + if path == docs_root: + raise OSError("broken listing") + return original_iterdir(path) + + monkeypatch.setattr(Path, "iterdir", iterdir) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + + assert pr_help_message._iter_help_docs(docs_root) == [] + logger.opt.assert_called_once_with(exception=True) + logger.opt.return_value.error.assert_called_once_with( + f"Error while listing the documentation directory {docs_root}" + ) + + +def test_unreadable_document_is_skipped_when_another_document_loads(tmp_path, monkeypatch): + package_root = tmp_path / "pr_agent" + docs_root = package_root / "_help_docs" + _write_document(docs_root, "good.md", "readable") + bad_document = _write_document(docs_root, "bad.md", "unreadable") + _write_document(docs_root, "empty.md", " \n\t") + original_read_text = Path.read_text + logger = Mock() + + def read_text(document, *args, **kwargs): + if document == bad_document: + raise OSError("permission denied") + return original_read_text(document, *args, **kwargs) + + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + monkeypatch.setattr(Path, "read_text", read_text) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + + prompt, available_docs_files = pr_help_message._load_help_docs_prompt() + + assert "readable" in prompt + assert "/bad.md" not in prompt + assert "/empty.md" not in prompt + assert available_docs_files == {"good.md"} + logger.opt.assert_called_once_with(exception=True) + logger.opt.return_value.error.assert_called_once_with( + f"Error while reading the documentation file {bad_document}" + ) + + +@pytest.mark.asyncio +async def test_missing_corpus_fails_before_model_attempt(tmp_path, monkeypatch): + package_root = tmp_path / "installed" / "pr_agent" + package_root.mkdir(parents=True) + retry = AsyncMock() + logger = Mock() + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + monkeypatch.setattr( + pr_help_message, + "__file__", + str(tmp_path / "checkout" / "pr_agent" / "tools" / "pr_help_message.py"), + ) + monkeypatch.setattr(pr_help_message, "retry_with_fallback_models", retry) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + monkeypatch.setattr( + pr_help_message, + "get_settings", + lambda: SimpleNamespace(config=SimpleNamespace(publish_output=True)), + ) + + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = SimpleNamespace( + pr_url="https://example.com/org/repo/pull/1", + publish_comment=Mock(), + ) + tool.ai_handler = SimpleNamespace(chat_completion=AsyncMock()) + tool.question_str = "How does review work?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + + with pytest.raises(FileNotFoundError): + await tool.run() + retry.assert_not_awaited() + tool.ai_handler.chat_completion.assert_not_awaited() + tool.git_provider.publish_comment.assert_called_once_with(HELP_DOCS_UNAVAILABLE_MESSAGE) + logger.exception.assert_called_once() + assert "Unable to load the PR-Agent help documentation" in logger.exception.call_args.args[0] + + +@pytest.mark.parametrize("failure_method", ["resolve", "is_dir"]) +@pytest.mark.asyncio +async def test_source_fallback_failure_notifies_before_model_attempt(tmp_path, monkeypatch, failure_method): + package_root = tmp_path / "installed" / "pr_agent" + package_root.mkdir(parents=True) + source_module = tmp_path / "checkout" / "pr_agent" / "tools" / "pr_help_message.py" + source_error = OSError("broken source checkout") + retry = AsyncMock() + logger = Mock() + source_docs = source_module.resolve().parents[2] / "docs" / "docs" + + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + monkeypatch.setattr(pr_help_message, "__file__", str(source_module)) + if failure_method == "resolve": + original_resolve = Path.resolve + + def resolve(path, *args, **kwargs): + if path == source_module: + raise source_error + return original_resolve(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", resolve) + else: + original_is_dir = Path.is_dir + + def is_dir(path): + if path == source_docs: + raise source_error + return original_is_dir(path) + + monkeypatch.setattr(Path, "is_dir", is_dir) + monkeypatch.setattr(pr_help_message, "retry_with_fallback_models", retry) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + monkeypatch.setattr( + pr_help_message, + "get_settings", + lambda: SimpleNamespace(config=SimpleNamespace(publish_output=True)), + ) + + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = SimpleNamespace( + pr_url="https://example.com/org/repo/pull/1", + publish_comment=Mock(), + ) + tool.ai_handler = SimpleNamespace(chat_completion=AsyncMock()) + tool.question_str = "How does review work?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + + with pytest.raises(FileNotFoundError) as raised: + await tool.run() + + assert raised.value.__cause__ is source_error + retry.assert_not_awaited() + tool.ai_handler.chat_completion.assert_not_awaited() + tool.git_provider.publish_comment.assert_called_once_with(HELP_DOCS_UNAVAILABLE_MESSAGE) + logger.opt.assert_called_once_with(exception=True) + logger.opt.return_value.error.assert_called_once_with( + "Unable to inspect source-tree /help documentation" + ) + logger.exception.assert_called_once_with("Unable to load the PR-Agent help documentation") + + +@pytest.mark.asyncio +async def test_unreadable_corpus_fails_before_model_attempt(tmp_path, monkeypatch): + package_root = tmp_path / "pr_agent" + docs_root = package_root / "_help_docs" + unreadable_document = _write_document(docs_root, "tools/review.md", "unreadable") + retry = AsyncMock() + logger = Mock() + original_read_text = Path.read_text + + def read_text(document, *args, **kwargs): + if document == unreadable_document: + raise OSError("permission denied") + return original_read_text(document, *args, **kwargs) + + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + monkeypatch.setattr(Path, "read_text", read_text) + monkeypatch.setattr(pr_help_message, "retry_with_fallback_models", retry) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + monkeypatch.setattr( + pr_help_message, + "get_settings", + lambda: SimpleNamespace(config=SimpleNamespace(publish_output=False)), + ) + + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = SimpleNamespace( + pr_url="https://example.com/org/repo/pull/1", + publish_comment=Mock(), + ) + tool.ai_handler = SimpleNamespace(chat_completion=AsyncMock()) + tool.question_str = "How does review work?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + + with pytest.raises(FileNotFoundError): + await tool.run() + retry.assert_not_awaited() + tool.ai_handler.chat_completion.assert_not_awaited() + tool.git_provider.publish_comment.assert_not_called() + logger.opt.assert_called_once_with(exception=True) + logger.opt.return_value.error.assert_called_once_with( + f"Error while reading the documentation file {unreadable_document}" + ) + logger.exception.assert_called_once() + assert "Unable to load the PR-Agent help documentation" in logger.exception.call_args.args[0] + + +@pytest.mark.asyncio +async def test_empty_corpus_fails_before_model_attempt(tmp_path, monkeypatch): + package_root = tmp_path / "pr_agent" + docs_root = package_root / "_help_docs" + _write_document(docs_root, "empty.md", "") + _write_document(docs_root, "whitespace.md", " \n\t") + retry = AsyncMock() + logger = Mock() + monkeypatch.setattr(pr_help_message, "package_files", lambda _package: package_root) + monkeypatch.setattr(pr_help_message, "retry_with_fallback_models", retry) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + monkeypatch.setattr( + pr_help_message, + "get_settings", + lambda: SimpleNamespace(config=SimpleNamespace(publish_output=False)), + ) + + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = SimpleNamespace( + pr_url="https://example.com/org/repo/pull/1", + publish_comment=Mock(), + ) + tool.ai_handler = SimpleNamespace(chat_completion=AsyncMock()) + tool.question_str = "How does review work?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + + with pytest.raises(FileNotFoundError): + await tool.run() + retry.assert_not_awaited() + tool.ai_handler.chat_completion.assert_not_awaited() + tool.git_provider.publish_comment.assert_not_called() + logger.exception.assert_called_once() + assert "Unable to load the PR-Agent help documentation" in logger.exception.call_args.args[0] + + +@pytest.mark.asyncio +async def test_notification_failure_preserves_original_corpus_error(monkeypatch): + original_error = FileNotFoundError("missing corpus") + retry = AsyncMock() + logger = Mock() + + def fail_load(): + raise original_error + + monkeypatch.setattr(pr_help_message, "_load_help_docs_prompt", fail_load) + monkeypatch.setattr(pr_help_message, "retry_with_fallback_models", retry) + monkeypatch.setattr(pr_help_message, "get_logger", lambda: logger) + monkeypatch.setattr( + pr_help_message, + "get_settings", + lambda: SimpleNamespace(config=SimpleNamespace(publish_output=True)), + ) + + tool = PRHelpMessage.__new__(PRHelpMessage) + tool.git_provider = SimpleNamespace( + pr_url="https://example.com/org/repo/pull/1", + publish_comment=Mock(side_effect=RuntimeError("provider unavailable")), + ) + tool.ai_handler = SimpleNamespace(chat_completion=AsyncMock()) + tool.question_str = "How does review work?" + tool.return_as_string = False + tool.vars = {"question": tool.question_str, "snippets": ""} + + with pytest.raises(FileNotFoundError) as raised: + await tool.run() + + assert raised.value is original_error + retry.assert_not_awaited() + tool.ai_handler.chat_completion.assert_not_awaited() + tool.git_provider.publish_comment.assert_called_once_with(HELP_DOCS_UNAVAILABLE_MESSAGE) + assert logger.exception.call_count == 2 + assert logger.exception.call_args_list[0].args == ("Unable to load the PR-Agent help documentation",) + assert logger.exception.call_args_list[1].args == ( + "Unable to publish the help documentation failure message", + ) diff --git a/tests/unittest/test_pr_line_questions_context_budget.py b/tests/unittest/test_pr_line_questions_context_budget.py index bec2ceb38f..118e4c3d14 100644 --- a/tests/unittest/test_pr_line_questions_context_budget.py +++ b/tests/unittest/test_pr_line_questions_context_budget.py @@ -6,6 +6,8 @@ from litellm import token_counter import pr_agent.tools.pr_line_questions as plq +from pr_agent.algo.token_budget import MESSAGE_FRAMING_TOKEN_ALLOWANCE, REPLY_FRAMING_TOKEN_ALLOWANCE +from pr_agent.algo.token_handler import TokenHandler from pr_agent.config_loader import get_settings from tests.unittest._settings_helpers import restore_settings, snapshot_settings @@ -203,9 +205,9 @@ async def test_ask_line_uses_attempted_model_for_non_gpt_prompt_budget(monkeypat counter_calls = [] - def model_aware_counter(*, model, messages): - counter_calls.append(model) - return sum(len(message["content"]) for message in messages) + def model_aware_count(self, text, force_accurate=False): + counter_calls.append(self.model) + return len(text) try: settings.set("config.model", "gpt-4o") @@ -222,19 +224,18 @@ def model_aware_counter(*, model, messages): settings.set("side", "RIGHT") settings.set("file_name", "src/example.py") settings.set("comment_id", 100) - monkeypatch.setattr(plq, "token_counter", model_aware_counter, raising=False) + monkeypatch.setattr(TokenHandler, "count_tokens", model_aware_count) await question.run() request = next(item for item in ai_handler.requests if item["model"] == "claude-2") assert "claude-2" in counter_calls - assert model_aware_counter( - model="claude-2", - messages=[ - {"role": "system", "content": request["system"]}, - {"role": "user", "content": request["user"]}, - ], - ) <= 2900 + assert ( + len(request["system"] + request["user"]) + + 2 * MESSAGE_FRAMING_TOKEN_ALLOWANCE + + REPLY_FRAMING_TOKEN_ALLOWANCE + <= 2900 + ) assert "reply 199" in request["user"] assert provider.replies == [(100, "answer")] finally: diff --git a/tests/unittest/test_pr_processing_core.py b/tests/unittest/test_pr_processing_core.py index 13ad4a9928..59f0aacb8f 100644 --- a/tests/unittest/test_pr_processing_core.py +++ b/tests/unittest/test_pr_processing_core.py @@ -4,6 +4,7 @@ import pytest import pr_agent.algo.pr_processing as pr_processing +import pr_agent.algo.token_budget as token_budget from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo from pr_agent.algo.utils import ModelType from pr_agent.config_loader import get_settings @@ -13,22 +14,1214 @@ class FakeTokenHandler: def __init__(self, prompt_tokens=100): self.prompt_tokens = prompt_tokens + self.count_calls = 0 def count_tokens(self, patch): + self.count_calls += 1 return len(patch.split()) class FakeProvider: def __init__(self, files): self.files = files + self.diff_calls = 0 + self.language_calls = 0 def get_diff_files(self): + self.diff_calls += 1 return self.files def get_languages(self): + self.language_calls += 1 return {"Python": 100} +class CharacterTokenHandler(FakeTokenHandler): + def count_tokens(self, patch): + self.count_calls += 1 + return len(patch) + + +class NonMonotoneTokenHandler(FakeTokenHandler): + def count_tokens(self, patch): + self.count_calls += 1 + letters = "".join( + line for line in patch.splitlines() if line in {"A", "B", "C", "D", "E"} + ) + prefix_counts = { + "A": 1, + "AB": 6, + "ABC": 4, + "ABCD": 4, + "ABCDE": 6, + "BCD": 3, + "BCDE": 6, + } + return prefix_counts.get(letters, len(letters)) + + +def _rendered_budget_files(): + lines = [ + 'F6[SZCg 3utmp{/(o8HXGWIwSROm2l(ULv"2d":{', + "E'57U/iCFe V9\\'2t:_JBD=d4W{il2T'zdAMWM)]", + ] + return [ + FilePatchInfo("old\n", lines[i % 2] + "\n", + "@@ -1 +1 @@\n-old\n+" + lines[i % 2] + "\n", + f"file_{i}.py", edit_type=EDIT_TYPE.MODIFIED) + for i in range(4) + ] + + +@pytest.mark.parametrize("add_line_numbers", [False, True]) +def test_extended_diff_total_counts_the_rendered_join(add_line_numbers): + handler = CharacterTokenHandler(prompt_tokens=11) + patches, total, per_patch = pr_processing.pr_generate_extended_diff( + [{"files": _rendered_budget_files()}], handler, add_line_numbers, + ) + + assert per_patch == [handler.count_tokens(patch) for patch in patches] + assert total == handler.prompt_tokens + handler.count_tokens("\n".join(patches)) + + +@pytest.mark.parametrize("packing_path", ["single", "multi"]) +@pytest.mark.parametrize("add_line_numbers", [False, True]) +def test_fast_path_rejects_a_join_that_exceeds_the_reserved_limit( + monkeypatch, packing_path, add_line_numbers, +): + handler = CharacterTokenHandler(prompt_tokens=11) + patches, _, individual_counts = pr_processing.pr_generate_extended_diff( + [{"files": _rendered_budget_files()}], handler, add_line_numbers, + ) + reserve = 5_000 + limit = handler.prompt_tokens + sum(individual_counts) + reserve + 1 + assert handler.prompt_tokens + handler.count_tokens("\n".join(patches)) + reserve > limit + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda langs, files: [{"files": files}]) + monkeypatch.setattr(pr_processing, "extend_patch", lambda original, patch, *args, **kwargs: patch) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: limit) + monkeypatch.setattr( + pr_processing, "pr_generate_compressed_diff", + lambda *args, **kwargs: ([["compressed"]], [12], [], [], {}, [[]]), + ) + monkeypatch.setattr(pr_processing, "_pack_pr_multi_diffs", lambda *args: ["compressed"]) + + if packing_path == "single": + result = pr_processing.get_pr_diff( + FakeProvider(_rendered_budget_files()), handler, "model", + add_line_numbers_to_hunks=add_line_numbers, + output_token_reserve=lambda model, default: reserve, + ) + assert result == "compressed" + else: + result = pr_processing.get_pr_multi_diffs( + FakeProvider(_rendered_budget_files()), handler, "model", + add_line_numbers=add_line_numbers, + output_token_reserve=lambda model, default: reserve, + ) + assert result == ["compressed"] + + +def test_real_encoder_extended_diff_total_includes_non_additive_join(monkeypatch): + import tiktoken + + encoder = tiktoken.get_encoding("o200k_base") + + class EncoderHandler(FakeTokenHandler): + def count_tokens(self, patch): + return len(encoder.encode(patch)) + + handler = EncoderHandler(prompt_tokens=11) + patches, total, per_patch = pr_processing.pr_generate_extended_diff( + [{"files": _rendered_budget_files()}], handler, False, + ) + rendered_count = handler.count_tokens("\n".join(patches)) + assert rendered_count > sum(per_patch) + assert total == handler.prompt_tokens + rendered_count + + reserve = 1_500 + limit = handler.prompt_tokens + sum(per_patch) + reserve + file_dict = { + file.filename: {"patch": file.patch, "tokens": handler.count_tokens(file.patch), + "edit_type": file.edit_type} + for file in _rendered_budget_files() + } + _, compressed, remaining, _ = pr_processing.generate_full_patch( + False, + file_dict, + limit - reserve - handler.prompt_tokens, + list(file_dict), + handler, + hard_token_budget=limit - 1_000 - handler.prompt_tokens, + ) + assert remaining + assert handler.prompt_tokens + handler.count_tokens("\n".join(compressed)) + reserve <= limit + + transformed = { + name: {"patch": patch, "tokens": tokens} + for name, patch, tokens in zip(file_dict, patches, per_patch, strict=True) + } + chunks = pr_processing._pack_pr_multi_diffs( + transformed, handler, 5, False, limit - reserve - handler.prompt_tokens, + ) + assert len(chunks) > 1 + assert all(handler.prompt_tokens + handler.count_tokens(chunk) + reserve <= limit for chunk in chunks) + + +@pytest.mark.parametrize("convert_line_numbers", [False, True]) +@pytest.mark.parametrize("overflow", [0, 1]) +def test_compressed_packing_counts_join_before_admitting_file(convert_line_numbers, overflow): + handler = CharacterTokenHandler(prompt_tokens=11) + file_dict = { + name: {"patch": "+ alpha", "tokens": 7, "edit_type": EDIT_TYPE.MODIFIED} + for name in ["a.py", "b.py"] + } + rendered = [ + "\n\n+ alpha" if convert_line_numbers else f"\n\n## File: '{name}'\n\n+ alpha\n" + for name in file_dict + ] + reserve = 1_500 + limit = handler.prompt_tokens + handler.count_tokens("\n".join(rendered)) + reserve - overflow + + total, patches, remaining, included = pr_processing.generate_full_patch( + convert_line_numbers, + file_dict, + limit - reserve - handler.prompt_tokens, + list(file_dict), + handler, + hard_token_budget=limit - 1_000 - handler.prompt_tokens, + ) + + assert total == handler.prompt_tokens + handler.count_tokens("\n".join(patches)) + assert total + reserve <= limit + assert included == (["a.py", "b.py"] if overflow == 0 else ["a.py"]) + assert remaining == ([] if overflow == 0 else ["b.py"]) + + +@pytest.mark.parametrize("overflow", [0, 1]) +@pytest.mark.parametrize("max_calls", [1, 2]) +def test_multi_packing_recounts_non_additive_join_and_preserves_remaining( + monkeypatch, overflow, max_calls, +): + class NonAdditiveHandler(CharacterTokenHandler): + def count_tokens(self, patch): + return len(patch) + (3 if "\n" in patch else 0) + + handler = NonAdditiveHandler(prompt_tokens=11) + reserve = 1_500 + limit = handler.prompt_tokens + handler.count_tokens("A\nB") + reserve - overflow + file_dict = {name: {"patch": patch, "tokens": 1} for name, patch in [("a.py", "A"), ("b.py", "B")]} + + chunks, remaining = pr_processing._pack_pr_multi_diffs( + file_dict, handler, max_calls, True, limit - reserve - handler.prompt_tokens, + ) + + assert all(handler.prompt_tokens + handler.count_tokens(chunk) + reserve <= limit for chunk in chunks) + assert chunks == (["A\nB"] if overflow == 0 else ["A", "B"][:max_calls]) + assert remaining == (["b.py"] if overflow and max_calls == 1 else []) + + +@pytest.mark.parametrize( + ("max_calls", "expected_chunks", "expected_remaining"), + [ + (2, ["A", "B\nC\nD"], ["e.py"]), + (3, ["A", "B\nC\nD", "E"], []), + ], +) +def test_multi_packing_repairs_non_monotone_prefixes_in_order( + max_calls, expected_chunks, expected_remaining, +): + handler = NonMonotoneTokenHandler() + file_dict = { + f"{patch.lower()}.py": {"patch": patch, "tokens": 1} + for patch in "ABCDE" + } + + chunks, remaining = pr_processing._pack_pr_multi_diffs( + file_dict, handler, max_calls, True, token_budget=5, + ) + + assert chunks == expected_chunks + assert remaining == expected_remaining + assert all(handler.count_tokens(chunk) <= 5 for chunk in chunks) + + +def test_compressed_packing_repairs_non_monotone_prefixes_in_order(): + handler = NonMonotoneTokenHandler(prompt_tokens=11) + file_dict = { + f"{patch.lower()}.py": { + "patch": patch, + "tokens": 1, + "edit_type": EDIT_TYPE.MODIFIED, + } + for patch in "ABCDE" + } + + total, patches, remaining, included = pr_processing.generate_full_patch( + True, + file_dict, + soft_token_budget=5, + remaining_files_list_prev=list(file_dict), + token_handler=handler, + hard_token_budget=5, + ) + + assert total == handler.prompt_tokens + 1 + assert patches == ["\n\nA"] + assert included == ["a.py"] + assert remaining == ["b.py", "c.py", "d.py", "e.py"] + + +def test_extended_diff_counts_trimmed_rendering(): + class StripSensitiveHandler(CharacterTokenHandler): + def count_tokens(self, patch): + return 100 if patch and patch == patch.strip() else super().count_tokens(patch) + + handler = StripSensitiveHandler(prompt_tokens=11) + _, total, _ = pr_processing.pr_generate_extended_diff( + [{"files": [_rendered_budget_files()[0]]}], handler, False, + ) + + assert total == handler.prompt_tokens + 100 + + +def test_compressed_packing_rejects_trimmed_overflow(): + class StripSensitiveHandler(CharacterTokenHandler): + def count_tokens(self, patch): + return 100 if patch == "AB" else super().count_tokens(patch) + + handler = StripSensitiveHandler(prompt_tokens=0) + total, patches, remaining, included = pr_processing.generate_full_patch( + True, + {"a.py": {"patch": "AB", "tokens": 2, "edit_type": EDIT_TYPE.MODIFIED}}, + soft_token_budget=4, + remaining_files_list_prev=["a.py"], + token_handler=handler, + hard_token_budget=4, + ) + + assert total == 0 + assert patches == [] + assert remaining == ["a.py"] + assert included == [] + + +@pytest.mark.parametrize("policy", ["skip", "clip"]) +def test_multi_packing_does_not_assume_stripping_reduces_tokens(monkeypatch, policy): + class StripSensitiveHandler(CharacterTokenHandler): + def count_tokens(self, patch): + return 100 if patch == "AB" else len(patch) + + handler = StripSensitiveHandler(prompt_tokens=0) + settings = get_settings() + original_policy = settings.config.get("large_patch_policy", "skip") + settings.config.large_patch_policy = policy + monkeypatch.setattr(pr_processing, "clip_tokens", lambda patch, *args, **kwargs: patch) + try: + chunks, remaining = pr_processing._pack_pr_multi_diffs( + {"a.py": {"patch": " AB ", "tokens": 4}}, handler, 2, True, 4, + ) + finally: + settings.config.large_patch_policy = original_policy + + assert chunks == [] + assert remaining == ["a.py"] + + +def test_multi_packing_clips_with_exact_single_patch_count(monkeypatch): + class StripSensitiveHandler(CharacterTokenHandler): + def count_tokens(self, patch): + return 100 if patch == "AB" else len(patch) + + observed_counts = [] + + def clip_with_reported_count(patch, max_tokens, *, num_input_tokens, **kwargs): + observed_counts.append(num_input_tokens) + return patch if num_input_tokens <= max_tokens else "A" + + handler = StripSensitiveHandler(prompt_tokens=0) + settings = get_settings() + original_policy = settings.config.get("large_patch_policy", "skip") + settings.config.large_patch_policy = "clip" + monkeypatch.setattr(pr_processing, "clip_tokens", clip_with_reported_count) + try: + chunks, remaining = pr_processing._pack_pr_multi_diffs( + {"a.py": {"patch": " AB ", "tokens": 4}}, handler, 2, True, 4, + ) + finally: + settings.config.large_patch_policy = original_policy + + assert chunks == ["A"] + assert remaining == [] + assert observed_counts == [100] + + +def test_fresh_and_prepared_multi_diffs_fit_the_same_rendered_boundary(monkeypatch): + handler = CharacterTokenHandler(prompt_tokens=11) + files = _rendered_budget_files() + patches, _, _ = pr_processing.pr_generate_extended_diff([{"files": files}], handler, True) + reserve = 5_000 + limit = handler.prompt_tokens + handler.count_tokens("\n".join(patches[:2])) + reserve - 1 + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: limit) + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda langs, files: [{"files": files}]) + monkeypatch.setattr(pr_processing, "extend_patch", lambda original, patch, *args, **kwargs: patch) + provider = FakeProvider(files) + prepared = pr_processing.get_pr_diff( + provider, handler, "model", add_line_numbers_to_hunks=True, return_prepared=True, + output_token_reserve=lambda model, default: reserve, + ) + assert prepared.file_dict + + prepared_chunks = pr_processing.get_pr_multi_diffs( + provider, handler, "model", prepared_diff=prepared, return_remaining_files=True, + output_token_reserve=lambda model, default: reserve, + ) + fresh_chunks = pr_processing.get_pr_multi_diffs( + FakeProvider(_rendered_budget_files()), handler, "model", return_remaining_files=True, + output_token_reserve=lambda model, default: reserve, + ) + + assert prepared_chunks == fresh_chunks + chunks, remaining = prepared_chunks + assert not remaining + assert len(chunks) > 1 + assert all(handler.prompt_tokens + handler.count_tokens(chunk) + reserve <= limit for chunk in chunks) + assert provider.diff_calls == 1 + + +@pytest.mark.parametrize( + ("max_tokens", "expected_diff", "expected_tokens"), + [ + (9, "base", 7), + (10, "base\n\nx", 10), + ], +) +def test_append_metadata_section_reserves_exact_separator_capacity( + max_tokens, expected_diff, expected_tokens +): + token_handler = CharacterTokenHandler(prompt_tokens=3) + + final_diff, curr_token, _ = pr_processing._append_metadata_section( + "base", 7, "x", max_tokens, token_handler + ) + + assert final_diff == expected_diff + assert curr_token == expected_tokens + + +def test_append_metadata_section_omits_non_additive_clipped_candidate(monkeypatch): + class NonAdditiveTokenHandler(CharacterTokenHandler): + candidate_counts = 0 + + def count_tokens(self, patch): + if patch.startswith("A\n\n"): + self.candidate_counts += 1 + tokens = super().count_tokens(patch) + return tokens + 2 if patch.startswith("A\n\n") else tokens + + clip_calls = 0 + + def clip_with_marker(text, max_tokens, **kwargs): + nonlocal clip_calls + clip_calls += 1 + if len(text) <= max_tokens: + return text + return text[:max(0, max_tokens - 1)] + "…" + + monkeypatch.setattr(pr_processing, "clip_tokens", clip_with_marker) + token_handler = NonAdditiveTokenHandler(prompt_tokens=0) + + final_diff, curr_token, clipped = pr_processing._append_metadata_section( + "A", 1, "BBBB", 6, token_handler + ) + + assert clipped == "" + assert final_diff == "A" + assert curr_token == 1 + assert clip_calls == 1 + assert token_handler.candidate_counts == 1 + + +def test_append_metadata_section_omits_trimmed_overflow(): + class StripSensitiveHandler(CharacterTokenHandler): + def count_tokens(self, patch): + return 100 if patch == "A\n\nB" else super().count_tokens(patch) + + token_handler = StripSensitiveHandler(prompt_tokens=0) + final_diff, curr_token, clipped = pr_processing._append_metadata_section( + " A", 2, "B", 8, token_handler, + ) + + assert clipped == "" + assert final_diff == " A" + assert curr_token == 2 + + +def test_append_metadata_sections_keep_complete_diff_within_budget(): + token_handler = CharacterTokenHandler(prompt_tokens=3) + max_tokens = token_handler.prompt_tokens + len("base\n\naa\n\nbb") + final_diff = "base" + curr_token = token_handler.prompt_tokens + token_handler.count_tokens(final_diff) + + for section in ("aa", "bb"): + final_diff, curr_token, _ = pr_processing._append_metadata_section( + final_diff, curr_token, section, max_tokens, token_handler + ) + + assert final_diff == "base\n\naa\n\nbb" + assert curr_token == token_handler.prompt_tokens + token_handler.count_tokens(final_diff) + assert curr_token == max_tokens + + +@pytest.mark.parametrize( + ("edit_type", "heading"), + [ + (EDIT_TYPE.ADDED, pr_processing.ADDED_FILES_.strip()), + (EDIT_TYPE.MODIFIED, pr_processing.MORE_MODIFIED_FILES_.strip()), + (EDIT_TYPE.RENAMED, pr_processing.MORE_MODIFIED_FILES_.strip()), + (EDIT_TYPE.DELETED, pr_processing.DELETED_FILES_.strip()), + ], +) +def test_get_pr_diff_routes_each_metadata_type_through_bounded_append( + monkeypatch, edit_type, heading +): + token_handler = CharacterTokenHandler(prompt_tokens=0) + file_dict = {"metadata.py": {"edit_type": edit_type}} + appended_sections = [] + original_append_metadata_section = pr_processing._append_metadata_section + + def append_metadata_section(*args, **kwargs): + appended_sections.append(args[2]) + return original_append_metadata_section(*args, **kwargs) + + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 1_500) + monkeypatch.setattr(pr_processing, "_append_metadata_section", append_metadata_section) + monkeypatch.setattr( + pr_processing, + "sort_files_by_main_languages", + lambda languages, files: [{"files": files}], + ) + monkeypatch.setattr( + pr_processing, + "pr_generate_extended_diff", + lambda *args, **kwargs: (["full diff"], 1_500, []), + ) + monkeypatch.setattr( + pr_processing, + "pr_generate_compressed_diff", + lambda *args, **kwargs: ([["base"]], [1_498], [], [], file_dict, [[]]), + ) + + diff = pr_processing.get_pr_diff( + FakeProvider([]), + token_handler, + "model", + output_token_reserve=lambda model, default: 1, + ) + + assert heading in diff + assert "metadata.py" in diff + assert [section for section in appended_sections if section] == [f"{heading}\n\nmetadata.py"] + assert token_handler.prompt_tokens + token_handler.count_tokens(diff) <= 500 + + +def _make_budget_files(tokens_per_file=2_800): + return [ + FilePatchInfo( + base_file="old\n", + head_file="new\n", + patch="@@ -1 +1 @@\n-old\n+" + ("token " * tokens_per_file), + filename=f"file_{index}.py", + edit_type=EDIT_TYPE.MODIFIED, + ) + for index in range(2) + ] + + +@pytest.mark.parametrize("resolved", [None, 0, -1, True, "5000"]) +def test_output_token_reserve_rejects_unusable_values(resolved): + budget = token_budget.AttemptTokenBudget( + "model", object(), object(), 10_000, output_token_reserve=lambda model, default: resolved, + ) + + assert budget.resolve_output_reserve(1_500, preserve_minimum=True) == 1_500 + + +@pytest.mark.parametrize("default", [1_000, 1_500]) +@pytest.mark.parametrize("resolved", [1, 100, 999, 1_000, 1_200, 1_499, 1_500, 5_000]) +def test_output_token_reserve_keeps_the_legacy_minimum(default, resolved): + budget = token_budget.AttemptTokenBudget( + "model", object(), object(), 10_000, output_token_reserve=lambda model, fallback: resolved, + ) + + assert budget.resolve_output_reserve(default, preserve_minimum=True) == max(default, resolved) + + +@pytest.mark.parametrize("packing_path", ["single", "multi", "multiple_patchs", "prepared"]) +def test_small_output_reserve_preserves_legacy_packing(monkeypatch, packing_path): + monkeypatch.setattr(get_settings().config, "patch_extra_lines_before", 0) + monkeypatch.setattr(get_settings().config, "patch_extra_lines_after", 0) + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 6_500) + + def pack(**kwargs): + provider = FakeProvider(_make_budget_files()) + token_handler = FakeTokenHandler(prompt_tokens=100) + if packing_path == "single": + return pr_processing.get_pr_diff(provider, token_handler, "model", **kwargs) + if packing_path == "multiple_patchs": + return pr_processing.get_pr_diff_multiple_patchs(provider, token_handler, "model", **kwargs) + if packing_path == "prepared": + prepared = pr_processing.get_pr_diff( + provider, token_handler, "model", add_line_numbers_to_hunks=True, + return_prepared=True, **kwargs, + ) + return pr_processing.get_pr_multi_diffs( + provider, token_handler, "model", prepared_diff=prepared, **kwargs + ) + return pr_processing.get_pr_multi_diffs(provider, token_handler, "model", **kwargs) + + assert pack(output_token_reserve=lambda model, default: 100) == pack() + + +@pytest.mark.parametrize("packing_path", ["get_pr_diff", "get_pr_diff_multiple_patchs"]) +def test_compressed_paths_keep_distinct_soft_and_hard_floors(monkeypatch, packing_path): + monkeypatch.setattr(get_settings().config, "patch_extra_lines_before", 0) + monkeypatch.setattr(get_settings().config, "patch_extra_lines_after", 0) + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 6_500) + original_compressed_diff = pr_processing.pr_generate_compressed_diff + reserves = [] + + def compressed_diff(*args, **kwargs): + reserves.append((args[2], args[3])) + return original_compressed_diff(*args, **kwargs) + + monkeypatch.setattr(pr_processing, "pr_generate_compressed_diff", compressed_diff) + getattr(pr_processing, packing_path)( + FakeProvider(_make_budget_files()), FakeTokenHandler(prompt_tokens=100), "model", + output_token_reserve=lambda model, default: 1_200, + ) + + assert reserves == [(4_900, 5_200)] + + +def test_output_token_reserve_falls_back_independently_when_one_resolution_fails(): + calls = [] + + def resolve(model, default): + calls.append((model, default)) + if default == pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: + return 5_000 + raise RuntimeError("hard reserve unavailable") + + budget = token_budget.AttemptTokenBudget("model", object(), object(), 10_000, output_token_reserve=resolve) + + assert budget.resolve_output_reserve(1_500, preserve_minimum=True) == 5_000 + assert budget.resolve_output_reserve(1_000, preserve_minimum=True) == 1_000 + assert calls == [("model", 1_500), ("model", 1_000)] + + +def test_get_pr_multi_diffs_reserves_the_active_completion_allowance(monkeypatch): + settings = get_settings() + original = { + "patch_extra_lines_before": settings.config.patch_extra_lines_before, + "patch_extra_lines_after": settings.config.patch_extra_lines_after, + "large_patch_policy": settings.config.get("large_patch_policy", "skip"), + } + settings.config.patch_extra_lines_before = 0 + settings.config.patch_extra_lines_after = 0 + settings.config.large_patch_policy = "skip" + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 10_000) + + try: + default_chunks = pr_processing.get_pr_multi_diffs( + FakeProvider(_make_budget_files()), + FakeTokenHandler(prompt_tokens=100), + "model", + add_line_numbers=False, + ) + token_handler = FakeTokenHandler(prompt_tokens=100) + reserved_chunks = pr_processing.get_pr_multi_diffs( + FakeProvider(_make_budget_files()), + token_handler, + "model", + add_line_numbers=False, + output_token_reserve=lambda model, default: 5_000, + ) + finally: + for key, value in original.items(): + setattr(settings.config, key, value) + + assert len(default_chunks) == 1 + assert len(reserved_chunks) == 2 + assert all(token_handler.prompt_tokens + token_handler.count_tokens(chunk) <= 5_000 + for chunk in reserved_chunks) + + +def test_get_pr_diff_reserves_output_in_compressed_diff_and_metadata(monkeypatch): + settings = get_settings() + original = { + "patch_extra_lines_before": settings.config.patch_extra_lines_before, + "patch_extra_lines_after": settings.config.patch_extra_lines_after, + "verbosity_level": settings.config.verbosity_level, + } + settings.config.patch_extra_lines_before = 0 + settings.config.patch_extra_lines_after = 0 + settings.config.verbosity_level = 0 + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 10_000) + token_handler = FakeTokenHandler(prompt_tokens=100) + + try: + diff, remaining_files = pr_processing.get_pr_diff( + FakeProvider(_make_budget_files()), + token_handler, + "model", + return_remaining_files=True, + output_token_reserve=lambda model, default: 5_000, + ) + finally: + for key, value in original.items(): + setattr(settings.config, key, value) + + assert "file_0.py" in diff + assert "file_1.py" in diff + assert remaining_files == ["file_1.py"] + assert token_handler.prompt_tokens + token_handler.count_tokens(diff) <= 5_000 + + +def test_get_pr_diff_uses_the_dynamic_hard_reserve_for_metadata(monkeypatch): + settings = get_settings() + original = { + "patch_extra_lines_before": settings.config.patch_extra_lines_before, + "patch_extra_lines_after": settings.config.patch_extra_lines_after, + "verbosity_level": settings.config.verbosity_level, + } + settings.config.patch_extra_lines_before = 0 + settings.config.patch_extra_lines_after = 0 + settings.config.verbosity_level = 0 + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 10_000) + reserve_calls = [] + + def resolve(model, default): + reserve_calls.append((model, default)) + if default == pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD: + return 5_000 + return 7_500 + + token_handler = FakeTokenHandler(prompt_tokens=100) + + try: + diff, remaining_files = pr_processing.get_pr_diff( + FakeProvider(_make_budget_files()), + token_handler, + "model", + return_remaining_files=True, + output_token_reserve=resolve, + ) + finally: + for key, value in original.items(): + setattr(settings.config, key, value) + + assert "file_0.py" in diff + assert "file_1.py" not in diff + assert remaining_files == ["file_1.py"] + assert token_handler.prompt_tokens + token_handler.count_tokens(diff) > 10_000 - 7_500 + assert reserve_calls == [("model", 1_500), ("model", 1_000)] + + +def test_get_pr_diff_multiple_patchs_resolves_the_output_allowance(monkeypatch): + settings = get_settings() + original = { + "max_ai_calls": settings.pr_description.max_ai_calls, + "verbosity_level": settings.config.verbosity_level, + } + settings.pr_description.max_ai_calls = 4 + settings.config.verbosity_level = 0 + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 10_000) + + try: + default_result = pr_processing.get_pr_diff_multiple_patchs( + FakeProvider(_make_budget_files()), FakeTokenHandler(prompt_tokens=100), "model" + ) + reserved_result = pr_processing.get_pr_diff_multiple_patchs( + FakeProvider(_make_budget_files()), + FakeTokenHandler(prompt_tokens=100), + "model", + output_token_reserve=lambda model, default: 5_000, + ) + finally: + settings.pr_description.max_ai_calls = original["max_ai_calls"] + settings.config.verbosity_level = original["verbosity_level"] + + assert len(default_result[0]) == 1 + assert len(reserved_result[0]) == 2 + + +def test_prepared_multi_diffs_apply_the_current_attempt_reserve(monkeypatch): + token_handler = FakeTokenHandler(prompt_tokens=100) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 10_000) + file_dict = { + f"file_{index}.py": { + "patch": "token " * 2_800, + "tokens": 2_800, + "edit_type": EDIT_TYPE.MODIFIED, + } + for index in range(2) + } + prepared = pr_processing.PreparedPRDiff( + diff="prepared", + remaining_files_list=[], + file_dict=file_dict, + files_by_name={}, + model="model", + add_line_numbers_to_hunks=True, + token_handler=token_handler, + ) + + chunks = pr_processing.get_pr_multi_diffs( + FakeProvider([]), + token_handler, + "model", + prepared_diff=prepared, + output_token_reserve=lambda model, default: 5_000, + ) + + assert len(chunks) == 2 + + +def test_direct_compressed_helpers_accept_explicit_numeric_budgets(monkeypatch): + context_window = 10_000 + prompt_tokens = 100 + soft_token_budget = context_window - pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - prompt_tokens + hard_token_budget = context_window - pr_processing.OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD - prompt_tokens + monkeypatch.setattr( + token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: context_window + ) + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + file_dict = { + "file.py": {"patch": "+ change", "tokens": 2, "edit_type": EDIT_TYPE.MODIFIED}, + } + token_handler = FakeTokenHandler(prompt_tokens=prompt_tokens) + + total, patches, remaining_files, files_in_patch = pr_processing.generate_full_patch( + False, + file_dict, + soft_token_budget, + ["file.py"], + token_handler, + hard_token_budget=hard_token_budget, + ) + + expected_compressed = pr_processing.pr_generate_compressed_diff( + [{"files": _make_budget_files(tokens_per_file=10)[:1]}], + FakeTokenHandler(prompt_tokens), + soft_token_budget, + hard_token_budget, + False, + True, + ) + actual_compressed = pr_processing.get_pr_diff_multiple_patchs( + FakeProvider(_make_budget_files(tokens_per_file=10)[:1]), + FakeTokenHandler(prompt_tokens), + "model", + ) + + assert total == token_handler.prompt_tokens + token_handler.count_tokens("\n".join(patches)) + assert remaining_files == [] + assert files_in_patch == ["file.py"] + assert actual_compressed == expected_compressed + + +def test_generate_full_patch_preserves_soft_boundary_equality(): + token_handler = FakeTokenHandler(prompt_tokens=100) + file_dict = { + "file.py": {"patch": "+ exact boundary", "tokens": 2, "edit_type": EDIT_TYPE.MODIFIED}, + } + rendered_tokens = token_handler.count_tokens("\n\n## File: 'file.py'\n\n+ exact boundary\n") + _, _, remaining_files, files_in_patch = pr_processing.generate_full_patch( + False, + file_dict, + rendered_tokens, + ["file.py"], + token_handler, + hard_token_budget=rendered_tokens, + ) + + assert files_in_patch == ["file.py"] + assert remaining_files == [] + + +def test_generate_full_patch_preserves_hard_boundary_equality(): + token_handler = FakeTokenHandler(prompt_tokens=100) + file_dict = { + "file.py": {"patch": "+ exact boundary", "tokens": 2, "edit_type": EDIT_TYPE.MODIFIED}, + } + + _, _, remaining_files, files_in_patch = pr_processing.generate_full_patch( + False, + file_dict, + 4_000, + ["file.py"], + token_handler, + hard_token_budget=0, + ) + + assert files_in_patch == ["file.py"] + assert remaining_files == [] + + +def test_pack_pr_multi_diffs_preserves_soft_boundary_equality(): + token_handler = FakeTokenHandler(prompt_tokens=100) + file_dict = { + "file.py": {"patch": "exact boundary", "tokens": 2, "edit_type": EDIT_TYPE.MODIFIED}, + } + chunks = pr_processing._pack_pr_multi_diffs( + file_dict, + token_handler, + 1, + False, + file_dict["file.py"]["tokens"], + ) + + assert chunks == ["exact boundary"] + + +@pytest.mark.parametrize(("extra_capacity", "uses_full_diff"), [(0, False), (1, True)]) +@pytest.mark.parametrize("reserve", [100, 1_000, 1_200, 1_500, 5_000]) +def test_get_pr_diff_preserves_strict_full_diff_boundary(monkeypatch, extra_capacity, uses_full_diff, reserve): + settings = get_settings() + original_before = settings.config.patch_extra_lines_before + original_after = settings.config.patch_extra_lines_after + settings.config.patch_extra_lines_before = 0 + settings.config.patch_extra_lines_after = 0 + original_generate_extended_diff = pr_processing.pr_generate_extended_diff + probe_handler = FakeTokenHandler(prompt_tokens=100) + probe_files = _make_budget_files(tokens_per_file=1)[:1] + probe_patches, probe_total, _ = original_generate_extended_diff( + [{"files": probe_files}], probe_handler, False, patch_extra_lines_before=0, patch_extra_lines_after=0 + ) + full_diff = "\n".join(probe_patches) + context_window = probe_total + max(reserve, 1_500) + extra_capacity + + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr( + token_budget, + "get_max_tokens", + lambda model, ignore_max_model_tokens=False: context_window, + ) + monkeypatch.setattr( + pr_processing, + "pr_generate_compressed_diff", + lambda *args, **kwargs: ([["compressed"]], [101], [], [], {}, [[]]), + ) + + try: + diff = pr_processing.get_pr_diff( + FakeProvider(_make_budget_files(tokens_per_file=1)[:1]), + FakeTokenHandler(prompt_tokens=100), + "model", + output_token_reserve=lambda model, default: reserve, + ) + finally: + settings.config.patch_extra_lines_before = original_before + settings.config.patch_extra_lines_after = original_after + + assert diff == (full_diff if uses_full_diff else "compressed") + + +@pytest.mark.parametrize(("extra_capacity", "uses_full_diff"), [(0, False), (1, True)]) +@pytest.mark.parametrize("reserve", [100, 1_000, 1_200, 1_500, 5_000]) +def test_get_pr_multi_diffs_preserves_strict_full_diff_boundary( + monkeypatch, extra_capacity, uses_full_diff, reserve, +): + settings = get_settings() + original_before = settings.config.patch_extra_lines_before + original_after = settings.config.patch_extra_lines_after + settings.config.patch_extra_lines_before = 0 + settings.config.patch_extra_lines_after = 0 + packed_budgets = [] + original_generate_extended_diff = pr_processing.pr_generate_extended_diff + probe_handler = FakeTokenHandler(prompt_tokens=100) + probe_files = _make_budget_files(tokens_per_file=1)[:1] + probe_patches, probe_total, _ = original_generate_extended_diff( + [{"files": probe_files}], probe_handler, True, patch_extra_lines_before=0, patch_extra_lines_after=0 + ) + full_diff = "\n".join(probe_patches) + context_window = probe_total + max(reserve, 1_500) + extra_capacity + + def pack_diffs(*args): + packed_budgets.append(args[-1]) + return ["compressed"] + + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr( + token_budget, + "get_max_tokens", + lambda model, ignore_max_model_tokens=False: context_window, + ) + monkeypatch.setattr( + pr_processing, + "pr_generate_compressed_diff", + lambda *args, **kwargs: ([], [], [], [], {}, []), + ) + monkeypatch.setattr(pr_processing, "_pack_pr_multi_diffs", pack_diffs) + + try: + chunks = pr_processing.get_pr_multi_diffs( + FakeProvider(_make_budget_files(tokens_per_file=1)[:1]), + FakeTokenHandler(prompt_tokens=100), + "model", + output_token_reserve=lambda model, default: reserve, + ) + finally: + settings.config.patch_extra_lines_before = original_before + settings.config.patch_extra_lines_after = original_after + + assert chunks == ([full_diff] if uses_full_diff else ["compressed"]) + assert packed_budgets == ([] if uses_full_diff else [probe_total - probe_handler.prompt_tokens]) + + +def test_prepared_pr_diff_reuses_compressed_files_without_changing_chunks(monkeypatch): + settings = get_settings() + original = { + "patch_extra_lines_before": settings.config.patch_extra_lines_before, + "patch_extra_lines_after": settings.config.patch_extra_lines_after, + "large_patch_policy": settings.config.get("large_patch_policy", "skip"), + "verbosity_level": settings.config.verbosity_level, + } + settings.config.patch_extra_lines_before = 0 + settings.config.patch_extra_lines_after = 0 + settings.config.large_patch_policy = "skip" + settings.config.verbosity_level = 0 + + hunk_sizes = (20, 40, 60, 80) + hunks = [ + "@@ -1 +1 @@\n-old\n+" + ("alpha " * size) + for size in hunk_sizes + ] + files = [ + FilePatchInfo("old\n", "new\n", hunks[index], f"file_{index}.py", edit_type=EDIT_TYPE.MODIFIED) + for index in range(4) + ] + provider = FakeProvider(files) + token_handler = FakeTokenHandler(prompt_tokens=100) + + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 1_700) + + try: + prepared = pr_processing.get_pr_diff( + provider, + token_handler, + "tiny-model", + add_line_numbers_to_hunks=True, + return_remaining_files=True, + return_prepared=True, + ) + + assert isinstance(prepared, pr_processing.PreparedPRDiff) + assert prepared.file_dict + expected_order = [f"file_{index}.py" for index in range(3, -1, -1)] + assert list(prepared.file_dict) == expected_order + calls_after_prepare = token_handler.count_calls + + def unexpected_preparation(*args, **kwargs): + pytest.fail("Prepared patches must not be transformed again") + + with monkeypatch.context() as packing_patch: + packing_patch.setattr(pr_processing, "extend_patch", unexpected_preparation) + packing_patch.setattr(pr_processing, "handle_patch_deletions", unexpected_preparation) + packing_patch.setattr( + pr_processing, "decouple_and_convert_to_hunks_with_lines_numbers", unexpected_preparation, + ) + prepared_chunks = pr_processing.get_pr_multi_diffs( + provider, + token_handler, + "tiny-model", + max_calls=3, + add_line_numbers=True, + return_remaining_files=True, + prepared_diff=prepared, + ) + + fresh_provider = FakeProvider([ + FilePatchInfo("old\n", "new\n", hunks[index], f"file_{index}.py", edit_type=EDIT_TYPE.MODIFIED) + for index in range(4) + ]) + fresh_chunks = pr_processing.get_pr_multi_diffs( + fresh_provider, + FakeTokenHandler(prompt_tokens=100), + "tiny-model", + max_calls=3, + add_line_numbers=True, + return_remaining_files=True, + ) + + assert prepared_chunks == fresh_chunks + prepared_diff_list, _ = prepared_chunks + combined_chunks = "\n".join(prepared_diff_list) + assert [combined_chunks.index(filename) for filename in expected_order] == sorted( + combined_chunks.index(filename) for filename in expected_order + ) + # Bound exact candidate checks linearly while keeping preparation cached. + assert 0 < token_handler.count_calls - calls_after_prepare <= 6 * len(prepared.file_dict) + assert (provider.diff_calls, provider.language_calls) == (1, 1) + finally: + for key, value in original.items(): + setattr(settings.config, key, value) + + +def test_exact_packing_checks_stay_within_linear_bound_for_many_files(): + file_count = 400 + file_dict = { + f"file_{index}.py": { + "patch": f"patch {index}", + "tokens": 2, + "edit_type": EDIT_TYPE.MODIFIED, + } + for index in range(file_count) + } + + chunk_handler = FakeTokenHandler(prompt_tokens=100) + chunks = pr_processing._pack_pr_multi_diffs( + file_dict, + chunk_handler, + max_calls=5, + return_remaining_files=False, + token_budget=180, + ) + + assert len(chunks) == 5 + assert all(chunk_handler.count_tokens(chunk) <= 180 for chunk in chunks) + assert chunk_handler.count_calls < 3 * file_count + + compressed_handler = FakeTokenHandler(prompt_tokens=100) + _, patches, remaining, included = pr_processing.generate_full_patch( + False, + file_dict, + soft_token_budget=10_000, + remaining_files_list_prev=list(file_dict), + token_handler=compressed_handler, + hard_token_budget=10_000, + ) + + assert len(patches) == file_count + assert remaining == [] + assert len(included) == file_count + assert compressed_handler.count_calls < 3 * file_count + + +def test_overflow_repair_bounds_encoded_volume_for_many_files(): + class RepairProbeTokenHandler(FakeTokenHandler): + def __init__(self, prompt_tokens=100): + super().__init__(prompt_tokens) + self.encoded_characters = 0 + + def count_tokens(self, patch): + self.count_calls += 1 + self.encoded_characters += len(patch) + non_additive_drift = 200 if patch.count("patch-") > 1 else 0 + return len(patch) + non_additive_drift + + file_count = 400 + patch_length = 40 + file_dict = { + f"file_{index}.py": { + "patch": f"patch-{index:03d}".ljust(patch_length, "x"), + "tokens": patch_length, + "edit_type": EDIT_TYPE.MODIFIED, + } + for index in range(file_count) + } + joined_length = file_count * patch_length + file_count - 1 + + chunk_handler = RepairProbeTokenHandler() + chunks = pr_processing._pack_pr_multi_diffs( + file_dict, + chunk_handler, + max_calls=2, + return_remaining_files=False, + token_budget=joined_length, + ) + + assert len(chunks) == 2 + assert all(chunk_handler.count_tokens(chunk) <= joined_length for chunk in chunks) + assert chunk_handler.encoded_characters < 12 * joined_length + + compressed_handler = RepairProbeTokenHandler() + total, patches, remaining, included = pr_processing.generate_full_patch( + True, + file_dict, + soft_token_budget=joined_length + 2 * file_count, + remaining_files_list_prev=list(file_dict), + token_handler=compressed_handler, + hard_token_budget=joined_length + 2 * file_count, + ) + rendered = "\n".join(patches) + + assert total == compressed_handler.prompt_tokens + compressed_handler.count_tokens(rendered) + assert compressed_handler.count_tokens(rendered) <= joined_length + 2 * file_count + assert remaining + assert included + # Exact repair checks both the raw and Jinja-trimmed serializations without recounting per admission. + assert compressed_handler.encoded_characters < 24 * len( + "\n".join("\n\n" + data["patch"] for data in file_dict.values()) + ) + + +@pytest.mark.parametrize( + ("prepared_model", "requested_model", "prepared_line_numbers", "requested_line_numbers"), + [ + ("tiny-model", "other-model", True, True), + ("tiny-model", "tiny-model", False, False), + ], +) +def test_prepared_pr_diff_is_not_reused_across_model_or_patch_format( + monkeypatch, prepared_model, requested_model, prepared_line_numbers, requested_line_numbers +): + settings = get_settings() + original_verbosity_level = settings.config.verbosity_level + settings.config.verbosity_level = 0 + hunk = "@@ -1 +1 @@\n-old\n+" + ("alpha " * 60) + files = [ + FilePatchInfo("old\n", "new\n", hunk, f"file_{index}.py", edit_type=EDIT_TYPE.MODIFIED) + for index in range(4) + ] + provider = FakeProvider(files) + token_handler = FakeTokenHandler(prompt_tokens=100) + monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 1_700) + + try: + prepared = pr_processing.get_pr_diff( + provider, + token_handler, + prepared_model, + add_line_numbers_to_hunks=prepared_line_numbers, + return_remaining_files=True, + return_prepared=True, + ) + assert isinstance(prepared, pr_processing.PreparedPRDiff) + + pr_processing.get_pr_multi_diffs( + provider, + token_handler, + requested_model, + max_calls=3, + add_line_numbers=requested_line_numbers, + return_remaining_files=True, + prepared_diff=prepared, + ) + + assert (provider.diff_calls, provider.language_calls) == (2, 2) + finally: + settings.config.verbosity_level = original_verbosity_level + + @pytest.mark.parametrize( "call_diff", [ @@ -37,11 +1230,13 @@ def get_languages(self): lambda provider, token_handler: pr_processing.get_pr_multi_diffs(provider, token_handler, "model"), ], ) -def test_shared_diff_paths_propagate_project_rate_limit(call_diff): +def test_shared_diff_paths_propagate_project_rate_limit(monkeypatch, call_diff): class RateLimitedProvider(FakeProvider): def get_diff_files(self): raise RateLimitExceeded("rate limit exceeded") + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 10_000) + with pytest.raises(RateLimitExceeded, match="rate limit exceeded"): call_diff(RateLimitedProvider([]), FakeTokenHandler()) @@ -79,9 +1274,18 @@ def test_generate_full_patch_keeps_remaining_files_when_patch_exceeds_soft_budge total_tokens, patches, remaining_files, files_in_patch = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=max_tokens_model, + soft_token_budget=( + max_tokens_model + - pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD + - token_handler.prompt_tokens + ), remaining_files_list_prev=list(file_dict), token_handler=token_handler, + hard_token_budget=( + max_tokens_model + - pr_processing.OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD + - token_handler.prompt_tokens + ), ) assert total_tokens > token_handler.prompt_tokens @@ -108,9 +1312,10 @@ def count_tokens(self, patch): total_tokens, patches, remaining_files, files_in_patch = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=3_000, + soft_token_budget=3_000 - 1_500 - token_handler.prompt_tokens, remaining_files_list_prev=list(file_dict), token_handler=token_handler, + hard_token_budget=3_000 - 1_000 - token_handler.prompt_tokens, ) assert total_tokens > 3_000 - pr_processing.OUTPUT_BUFFER_TOKENS_HARD_THRESHOLD @@ -130,9 +1335,10 @@ def test_generate_full_patch_records_too_large_patch_files(): total_tokens, patches, remaining_files, files_in_patch = pr_processing.generate_full_patch( convert_hunks_to_line_numbers=False, file_dict=file_dict, - max_tokens_model=4_000, + soft_token_budget=4_000 - 1_500 - token_handler.prompt_tokens, remaining_files_list_prev=list(file_dict), token_handler=token_handler, + hard_token_budget=4_000 - 1_000 - token_handler.prompt_tokens, ) assert total_tokens > token_handler.prompt_tokens @@ -180,7 +1386,10 @@ def test_get_all_deployments_rejects_short_fallback_deployment_list(): settings.set("openai.fallback_deployments", original_fallback_deployments) -def test_get_pr_multi_diffs_clips_large_patch_when_policy_is_clip(monkeypatch): +@pytest.mark.parametrize(("context_limit", "reserve"), [(1_700, None), (5_200, 5_000)]) +def test_get_pr_multi_diffs_clips_large_patch_with_legacy_and_dynamic_reserve( + monkeypatch, context_limit, reserve +): settings = get_settings() original = { "patch_extra_lines_before": settings.config.patch_extra_lines_before, @@ -202,17 +1411,35 @@ def test_get_pr_multi_diffs_clips_large_patch_when_policy_is_clip(monkeypatch): ) provider = FakeProvider([file_info]) token_handler = FakeTokenHandler(prompt_tokens=100) + clip_budgets = [] monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 1700) - monkeypatch.setattr(pr_processing, "clip_tokens", lambda patch, *args, **kwargs: "clipped patch") + monkeypatch.setattr( + token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: context_limit + ) + + def clip_patch(patch, token_budget, **kwargs): + clip_budgets.append(token_budget) + return "clipped patch" + + monkeypatch.setattr(pr_processing, "clip_tokens", clip_patch) try: + reserve_kwargs = ( + {"output_token_reserve": lambda model, default: reserve} if reserve is not None else {} + ) diffs = pr_processing.get_pr_multi_diffs( - provider, token_handler, "tiny-model", max_calls=2, add_line_numbers=False + provider, + token_handler, + "tiny-model", + max_calls=2, + add_line_numbers=False, + **reserve_kwargs, ) assert diffs == ["clipped patch"] + expected_reserve = reserve if reserve is not None else pr_processing.OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD + assert clip_budgets == [context_limit - expected_reserve - token_handler.prompt_tokens] finally: settings.config.patch_extra_lines_before = original["patch_extra_lines_before"] settings.config.patch_extra_lines_after = original["patch_extra_lines_after"] @@ -247,7 +1474,7 @@ def _file(filename, patch): token_handler = FakeTokenHandler(prompt_tokens=100) monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 1700) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 1700) try: diffs, remaining_files = pr_processing.get_pr_multi_diffs( @@ -278,7 +1505,7 @@ def test_get_pr_multi_diffs_reports_no_remaining_files_when_the_whole_diff_fits( token_handler = FakeTokenHandler(prompt_tokens=100) monkeypatch.setattr(pr_processing, "sort_files_by_main_languages", lambda languages, files: [{"files": files}]) - monkeypatch.setattr(pr_processing, "get_max_tokens", lambda model: 100000) + monkeypatch.setattr(token_budget, "get_max_tokens", lambda model, ignore_max_model_tokens=False: 100000) try: diffs, remaining_files = pr_processing.get_pr_multi_diffs( diff --git a/tests/unittest/test_pr_questions_helpers.py b/tests/unittest/test_pr_questions_helpers.py index 9acf7d6f1a..175b591ee3 100644 --- a/tests/unittest/test_pr_questions_helpers.py +++ b/tests/unittest/test_pr_questions_helpers.py @@ -12,7 +12,7 @@ import pytest import pr_agent.tools.pr_line_questions as plq -from pr_agent.algo.utils import format_pr_questions_header +from pr_agent.algo.comment_identity import format_pr_questions_header from pr_agent.config_loader import get_settings from pr_agent.git_providers import AzureDevopsProvider from pr_agent.git_providers.codecommit_provider import CodeCommitProvider diff --git a/tests/unittest/test_pr_reviewer_core.py b/tests/unittest/test_pr_reviewer_core.py index 985b0b64c7..c442ef1127 100644 --- a/tests/unittest/test_pr_reviewer_core.py +++ b/tests/unittest/test_pr_reviewer_core.py @@ -3,17 +3,20 @@ import pytest +from pr_agent.algo.comment_identity import PRReviewHeader, PRReviewIdentity from pr_agent.algo.inline_comment_dedup import ( body_with_markers, get_inline_comment_store, key_issue_fingerprint, ) from pr_agent.algo.types import FilePatchInfo -from pr_agent.algo.utils import PRReviewHeader, PRReviewIdentity +from pr_agent.algo.utils import convert_to_markdown_v2 from pr_agent.config_loader import get_settings from pr_agent.git_providers.azuredevops_provider import AzureDevopsProvider from pr_agent.tools.pr_reviewer import PRReviewer, _review_failure_comment +_VALID_PREDICTION = "review:\n summary: prediction" + def _make_reviewer(git_provider=None): reviewer = PRReviewer.__new__(PRReviewer) @@ -87,7 +90,7 @@ def test_review_failure_comment_treats_quoted_false_as_disabled(): @pytest.mark.asyncio async def test_prepare_prediction_requests_remaining_files_and_preserves_tuple_result(): reviewer = _make_prediction_reviewer() - reviewer._get_prediction = AsyncMock(return_value="prediction") + reviewer._get_prediction = AsyncMock(return_value=_VALID_PREDICTION) with patch( "pr_agent.tools.pr_reviewer.get_pr_diff", @@ -105,34 +108,58 @@ async def test_prepare_prediction_requests_remaining_files_and_preserves_tuple_r ) assert reviewer.patches_diff == "diff" assert reviewer.remaining_files_list == ["src/one.py", "docs/two.md"] - assert reviewer.prediction == "prediction" + assert reviewer.prediction == _VALID_PREDICTION @pytest.mark.asyncio async def test_prepare_prediction_accepts_full_diff_string_when_token_budget_is_sufficient(): reviewer = _make_prediction_reviewer() - reviewer._get_prediction = AsyncMock(return_value="prediction") + reviewer._get_prediction = AsyncMock(return_value=_VALID_PREDICTION) with patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value="diff"): await reviewer._prepare_prediction("model") assert reviewer.patches_diff == "diff" assert reviewer.remaining_files_list == [] - assert reviewer.prediction == "prediction" + assert reviewer.prediction == _VALID_PREDICTION @pytest.mark.asyncio async def test_prepare_prediction_keeps_incremental_review_compatible_with_tuple_result(): reviewer = _make_prediction_reviewer() reviewer.incremental = SimpleNamespace(is_incremental=True) - reviewer._get_prediction = AsyncMock(return_value="prediction") + reviewer._get_prediction = AsyncMock(return_value=_VALID_PREDICTION) with patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["skipped.py"])): await reviewer._prepare_prediction("model") assert reviewer.patches_diff == "diff" assert reviewer.remaining_files_list == ["skipped.py"] - assert reviewer.prediction == "prediction" + assert reviewer.prediction == _VALID_PREDICTION + + +@pytest.mark.asyncio +async def test_final_review_fit_rejects_untracked_diff_clipping(): + reviewer = _make_prediction_reviewer() + reviewer.vars = {"diff": ""} + reviewer.ai_handler = SimpleNamespace(chat_completion=AsyncMock()) + + class ClippingBudget: + def fit_prompt_variable(self, _variables, _name, optional_text, **_kwargs): + return SimpleNamespace( + optional_text=optional_text[:-1], + system_prompt="system", + user_prompt="user", + ) + + with patch( + "pr_agent.tools.pr_reviewer.AttemptTokenBudget.for_attempt", + return_value=ClippingBudget(), + ): + with pytest.raises(ValueError, match="complete packed review diff"): + await reviewer._get_prediction("fallback-model", "complete-diff") + + reviewer.ai_handler.chat_completion.assert_not_awaited() def _render_review(reviewer, remaining_files, supports_gfm_markdown=False): @@ -211,6 +238,71 @@ def test_prepare_pr_review_leaves_original_content_unchanged_without_remaining_f assert "Review coverage" not in review +def test_prepare_pr_review_warns_on_invalid_model_output_without_changing_markdown(): + reviewer = _make_prediction_reviewer() + reviewer.prediction = "review:\n key_issues_to_review: wrong" + reviewer.git_provider.get_diff_files.return_value = [] + reviewer.git_provider.is_supported.return_value = False + reviewer.set_review_labels = MagicMock() + + with ( + patch("pr_agent.tools.pr_reviewer.load_yaml", return_value={"review": {"key_issues_to_review": "wrong"}}), + patch("pr_agent.tools.pr_reviewer.github_action_output"), + patch("pr_agent.tools.pr_reviewer.convert_to_markdown_v2", return_value="original review"), + patch("pr_agent.tools.pr_reviewer.get_logger") as get_logger, + ): + review = reviewer._prepare_pr_review() + + assert review == "original review" + get_logger.return_value.warning.assert_called_once() + warning = get_logger.return_value.warning.call_args.kwargs + assert warning["artifact"] == {"field": "review.key_issues_to_review", "value": "wrong"} + + +def test_prepare_pr_review_does_not_warn_for_valid_model_output(): + reviewer = _make_prediction_reviewer() + reviewer.prediction = "review:\n key_issues_to_review: []" + reviewer.git_provider.get_diff_files.return_value = [] + reviewer.git_provider.is_supported.return_value = False + reviewer.set_review_labels = MagicMock() + + with ( + patch("pr_agent.tools.pr_reviewer.load_yaml", return_value={"review": {"key_issues_to_review": []}}), + patch("pr_agent.tools.pr_reviewer.github_action_output"), + patch("pr_agent.tools.pr_reviewer.convert_to_markdown_v2", return_value="original review"), + patch("pr_agent.tools.pr_reviewer.get_logger") as get_logger, + ): + review = reviewer._prepare_pr_review() + + assert review == "original review" + get_logger.return_value.warning.assert_not_called() + + +def test_review_schema_requires_enabled_prompt_fields_only(): + reviewer = _make_prediction_reviewer() + reviewer.vars = {"require_tests": True} + with patch("pr_agent.tools.pr_reviewer.get_logger") as get_logger: + assert reviewer._validate_review_schema({"review": {"key_issues_to_review": []}}) is False + + warning = get_logger.return_value.warning.call_args.kwargs + assert warning["artifact"] == {"field": "review.relevant_tests", "value": None} + + reviewer.vars = {"require_tests": False} + get_logger.return_value.warning.reset_mock() + assert reviewer._validate_review_schema({"review": {"key_issues_to_review": []}}) is True + get_logger.return_value.warning.assert_not_called() + + +def test_review_schema_reports_none_for_missing_fields(): + reviewer = _make_prediction_reviewer() + with patch("pr_agent.tools.pr_reviewer.get_logger") as get_logger: + assert reviewer._validate_review_schema({"review": {}}) is False + + warning = get_logger.return_value.warning.call_args.kwargs + assert warning["artifact"]["field"] == "review.key_issues_to_review" + assert warning["artifact"]["value"] is None + + def test_prepare_pr_review_limits_coverage_footer_to_50_files(): reviewer = _make_prediction_reviewer() remaining_files = [f"file_{index}.py" for index in range(51)] @@ -1075,7 +1167,8 @@ def test_can_run_incremental_review_skips_auto_mode_without_new_commit(): assert reviewer._can_run_incremental_review() is False -def test_set_review_labels_replaces_stale_review_labels_and_keeps_user_labels(): +@pytest.fixture +def review_label_settings(): settings = get_settings() original = { "publish_output": settings.config.publish_output, @@ -1089,67 +1182,134 @@ def test_set_review_labels_replaces_stale_review_labels_and_keeps_user_labels(): settings.pr_reviewer.require_security_review = True settings.pr_reviewer.enable_review_labels_effort = True settings.pr_reviewer.enable_review_labels_security = True + yield + settings.config.publish_output = original["publish_output"] + settings.pr_reviewer.require_estimate_effort_to_review = original["require_estimate_effort_to_review"] + settings.pr_reviewer.require_security_review = original["require_security_review"] + settings.pr_reviewer.enable_review_labels_effort = original["enable_review_labels_effort"] + settings.pr_reviewer.enable_review_labels_security = original["enable_review_labels_security"] + + +def test_set_review_labels_replaces_stale_review_labels_and_keeps_user_labels(review_label_settings): git_provider = MagicMock() git_provider.get_pr_labels.return_value = ["Review effort 1/5", "Possible security concern", "keep-me"] reviewer = _make_reviewer(git_provider) data = { "review": { "estimated_effort_to_review_[1-5]": "3, moderate", - "security_concerns": "yes", + "security_concerns": "SQL injection: the order id is concatenated into the query\n", } } + reviewer.set_review_labels(data) + + git_provider.publish_labels.assert_called_once_with([ + "Review effort 3/5", + "Possible security concern", + "keep-me", + ]) + + +@pytest.mark.parametrize( + "security_concerns, expect_label", + [ + ("SQL injection: the order id is concatenated into the query\n", True), + ("Sensitive information exposure: the API key is written to the log\n", True), + ("No\n", False), + ("no", False), + (" No \n", False), + ("", False), + (None, False), + # A punctuated negative is not a recognised "no", so it labels and renders as a concern. + ("No.", True), + ], +) +def test_set_review_labels_security_label_matches_the_rendered_review_body( + review_label_settings, security_concerns, expect_label +): + git_provider = MagicMock() + git_provider.get_pr_labels.return_value = [] + reviewer = _make_reviewer(git_provider) + data = {"review": {"estimated_effort_to_review_[1-5]": "2", "security_concerns": security_concerns}} + + reviewer.set_review_labels(data) + + published = git_provider.publish_labels.call_args[0][0] + assert ("Possible security concern" in published) is expect_label + + body = convert_to_markdown_v2(data) + assert ("Security concerns" in body) is expect_label + + +@pytest.mark.parametrize("missing_value", [None, ""]) +def test_set_review_labels_keeps_existing_security_label_when_verdict_is_missing( + review_label_settings, missing_value +): + # A truncated model response leaves security_concerns missing (None), which + # is not a valid negative verdict, so an already published security alert + # label must be preserved instead of being filtered out. An empty string is + # a recognised negative and does clear the stale label, matching the body. + git_provider = MagicMock() + git_provider.get_pr_labels.return_value = ["Possible security concern", "keep-me"] + reviewer = _make_reviewer(git_provider) + data = {"review": {"estimated_effort_to_review_[1-5]": "2", "security_concerns": missing_value}} + + reviewer.set_review_labels(data) + + published = git_provider.publish_labels.call_args[0][0] + if missing_value is None: + assert "Possible security concern" in published + else: + assert "Possible security concern" not in published + assert "keep-me" in published + + +def test_set_review_labels_does_not_label_security_free_localized_review(review_label_settings): + # With a non-English response language the model is told to keep the exact + # English 'No' sentinel, so a security-free review must not get the label. + settings = get_settings() + original_language = settings.config.response_language + settings.config.response_language = "de-DE" try: + git_provider = MagicMock() + git_provider.get_pr_labels.return_value = [] + reviewer = _make_reviewer(git_provider) + data = {"review": {"estimated_effort_to_review_[1-5]": "2", "security_concerns": "No"}} + reviewer.set_review_labels(data) - git_provider.publish_labels.assert_called_once_with([ - "Review effort 3/5", - "Possible security concern", - "keep-me", - ]) + published = git_provider.publish_labels.call_args[0][0] + assert "Possible security concern" not in published finally: - settings.config.publish_output = original["publish_output"] - settings.pr_reviewer.require_estimate_effort_to_review = original["require_estimate_effort_to_review"] - settings.pr_reviewer.require_security_review = original["require_security_review"] - settings.pr_reviewer.enable_review_labels_effort = original["enable_review_labels_effort"] - settings.pr_reviewer.enable_review_labels_security = original["enable_review_labels_security"] + settings.config.response_language = original_language -def test_set_review_labels_skips_providers_without_label_support(): - settings = get_settings() - original = { - "publish_output": settings.config.publish_output, - "require_estimate_effort_to_review": settings.pr_reviewer.require_estimate_effort_to_review, - "require_security_review": settings.pr_reviewer.require_security_review, - "enable_review_labels_effort": settings.pr_reviewer.enable_review_labels_effort, - "enable_review_labels_security": settings.pr_reviewer.enable_review_labels_security, - } - settings.config.publish_output = True - settings.pr_reviewer.require_estimate_effort_to_review = True - settings.pr_reviewer.require_security_review = True - settings.pr_reviewer.enable_review_labels_effort = True - settings.pr_reviewer.enable_review_labels_security = True +def test_security_concerns_field_prompt_preserves_no_sentinel_for_localized_responses(): + # The prompt must keep the exact English 'No' sentinel even when the + # response language instructs the model to localize its answer, otherwise + # localized negatives are treated as security concerns by the label and the + # rendered body. + prompt = get_settings().pr_review_prompt.system + security_field = prompt.split("security_concerns: str = Field(description=")[1].split("\n")[0] + assert "Answer 'No'" in security_field + assert "do not translate it into another language" in security_field + + +def test_set_review_labels_skips_providers_without_label_support(review_label_settings): git_provider = MagicMock() git_provider.is_supported.return_value = False reviewer = _make_reviewer(git_provider) data = { "review": { "estimated_effort_to_review_[1-5]": "3, moderate", - "security_concerns": "yes", + "security_concerns": "SQL injection: the order id is concatenated into the query\n", } } - try: - reviewer.set_review_labels(data) + reviewer.set_review_labels(data) - git_provider.get_pr_labels.assert_not_called() - git_provider.publish_labels.assert_not_called() - finally: - settings.config.publish_output = original["publish_output"] - settings.pr_reviewer.require_estimate_effort_to_review = original["require_estimate_effort_to_review"] - settings.pr_reviewer.require_security_review = original["require_security_review"] - settings.pr_reviewer.enable_review_labels_effort = original["enable_review_labels_effort"] - settings.pr_reviewer.enable_review_labels_security = original["enable_review_labels_security"] + git_provider.get_pr_labels.assert_not_called() + git_provider.publish_labels.assert_not_called() def test_get_user_answers_collects_question_and_answer_from_issue_comments(): diff --git a/tests/unittest/test_pr_reviewer_fallback_validation.py b/tests/unittest/test_pr_reviewer_fallback_validation.py new file mode 100644 index 0000000000..bb8fdaa6e2 --- /dev/null +++ b/tests/unittest/test_pr_reviewer_fallback_validation.py @@ -0,0 +1,168 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from pr_agent.algo.pr_processing import retry_with_fallback_models +from pr_agent.config_loader import get_settings +from pr_agent.tools.pr_reviewer import PRReviewer +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +_TRACKED_KEYS = ( + "config.model", + "config.fallback_models", + "config.publish_output", + "config.is_auto_command", + "config.propagate_tool_errors", + "openai.deployment_id", + "openai.fallback_deployments", + "pr_reviewer.persistent_comment", +) + +_VALID_REVIEW = """review: + summary: fallback review +""" + + +@pytest.fixture +def fallback_models(): + snapshot = snapshot_settings(_TRACKED_KEYS) + settings = get_settings() + settings.set("config.model", "primary-model") + settings.set("config.fallback_models", ["fallback-model"]) + settings.set("openai.deployment_id", None) + settings.set("openai.fallback_deployments", []) + yield + restore_settings(snapshot) + + +def _make_reviewer(git_provider=None): + reviewer = PRReviewer.__new__(PRReviewer) + reviewer.git_provider = git_provider or MagicMock() + reviewer.token_handler = MagicMock() + reviewer.pr_url = "https://example/pr/1" + reviewer.incremental = SimpleNamespace(is_incremental=False) + reviewer.remaining_files_list = [] + reviewer.prediction = None + reviewer.vars = {} + return reviewer + + +@pytest.mark.asyncio +async def test_malformed_primary_review_uses_fallback_model(fallback_models): + reviewer = _make_reviewer() + reviewer._get_prediction = AsyncMock(side_effect=["not valid review yaml", _VALID_REVIEW]) + + with ( + patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value="diff"), + patch( + "pr_agent.tools.pr_reviewer.fit_related_tickets_to_prompt_budget", + side_effect=lambda _pr, raw_vars, _system, _user, _model, **_kwargs: ( + raw_vars, + reviewer.token_handler, + ), + ), + ): + await retry_with_fallback_models(reviewer._prepare_prediction, git_provider=reviewer.git_provider) + + assert reviewer._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + assert reviewer.prediction == _VALID_REVIEW + + +@pytest.mark.asyncio +async def test_empty_primary_diff_uses_fallback_model(fallback_models): + reviewer = _make_reviewer() + reviewer._get_prediction = AsyncMock(return_value=_VALID_REVIEW) + + def get_pr_diff(_provider, _handler, model, **_kwargs): + if model == "primary-model": + return "", ["src/too-large.py"] + return "fallback diff", [] + + with ( + patch("pr_agent.tools.pr_reviewer.get_pr_diff", side_effect=get_pr_diff), + patch( + "pr_agent.tools.pr_reviewer.fit_related_tickets_to_prompt_budget", + side_effect=lambda _pr, raw_vars, _system, _user, _model, **_kwargs: ( + raw_vars, + reviewer.token_handler, + ), + ), + ): + await retry_with_fallback_models( + reviewer._prepare_prediction, + git_provider=reviewer.git_provider, + ) + + reviewer._get_prediction.assert_awaited_once_with("fallback-model") + assert reviewer.patches_diff == "fallback diff" + assert reviewer.prediction == _VALID_REVIEW + + +@pytest.mark.asyncio +async def test_malformed_primary_then_valid_fallback_publishes_review_without_failure( + monkeypatch, fallback_models +): + progress_comment = MagicMock() + git_provider = MagicMock() + git_provider.get_files.return_value = ["app.py"] + git_provider.publish_comment.return_value = progress_comment + git_provider.should_publish_review_as_thread.return_value = False + git_provider.supports_review_comment_identity.return_value = False + reviewer = _make_reviewer(git_provider) + reviewer._get_prediction = AsyncMock(side_effect=["not yaml", _VALID_REVIEW]) + reviewer._prepare_pr_review = MagicMock(return_value="rendered fallback review") + + settings = get_settings() + settings.set("config.publish_output", True) + settings.set("config.is_auto_command", False) + settings.set("config.propagate_tool_errors", False) + settings.set("pr_reviewer.persistent_comment", False) + + monkeypatch.setattr("pr_agent.tools.pr_reviewer.extract_and_cache_pr_tickets", AsyncMock()) + monkeypatch.setattr( + "pr_agent.tools.pr_reviewer.fit_related_tickets_to_prompt_budget", + lambda _pr, raw_vars, _system, _user, _model, **_kwargs: (raw_vars, reviewer.token_handler), + ) + + with patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value="diff"): + await reviewer.run() + + assert reviewer._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + assert git_provider.publish_comment.call_args_list == [ + call("Preparing review...", is_temporary=True), + call("rendered fallback review"), + ] + assert all("Failed to review PR" not in str(args) for args in git_provider.publish_comment.call_args_list) + git_provider.remove_comment.assert_called_once_with(progress_comment) + + +@pytest.mark.asyncio +async def test_all_malformed_models_publish_one_failure_result(monkeypatch, fallback_models): + progress_comment = MagicMock() + git_provider = MagicMock() + git_provider.get_files.return_value = ["app.py"] + git_provider.publish_comment.return_value = progress_comment + reviewer = _make_reviewer(git_provider) + reviewer._get_prediction = AsyncMock(side_effect=["not yaml", "review: {}"]) + + settings = get_settings() + settings.set("config.publish_output", True) + settings.set("config.is_auto_command", False) + settings.set("config.propagate_tool_errors", False) + + monkeypatch.setattr("pr_agent.tools.pr_reviewer.extract_and_cache_pr_tickets", AsyncMock()) + monkeypatch.setattr( + "pr_agent.tools.pr_reviewer.fit_related_tickets_to_prompt_budget", + lambda _pr, raw_vars, _system, _user, _model, **_kwargs: (raw_vars, reviewer.token_handler), + ) + + with patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value="diff"): + await reviewer.run() + + assert reviewer._get_prediction.await_args_list == [call("primary-model"), call("fallback-model")] + assert git_provider.publish_comment.call_args_list == [ + call("Preparing review...", is_temporary=True), + call("Failed to review PR"), + ] + git_provider.remove_comment.assert_called_once_with(progress_comment) diff --git a/tests/unittest/test_pr_reviewer_finding_state.py b/tests/unittest/test_pr_reviewer_finding_state.py index b9468c2b9d..b04bd56180 100644 --- a/tests/unittest/test_pr_reviewer_finding_state.py +++ b/tests/unittest/test_pr_reviewer_finding_state.py @@ -4,26 +4,30 @@ import pytest -from pr_agent.algo.review_finding_state import ( - parse_review_state, - reconcile_review_findings, - serialize_review_state, -) -from pr_agent.algo.utils import ( +from pr_agent.algo.comment_identity import ( PRReviewHeader, PRReviewIdentity, add_pr_review_identity, comment_matches_identity, get_pr_review_comment_identifiers, ) +from pr_agent.algo.review_finding_state import ( + _render_resolved_section, + normalize_finding, + parse_review_state, + reconcile_review_findings, + serialize_review_state, +) from pr_agent.config_loader import get_settings from pr_agent.git_providers.azuredevops_provider import AzureDevopsProvider from pr_agent.git_providers.bitbucket_provider import BitbucketProvider from pr_agent.git_providers.bitbucket_server_provider import BitbucketServerProvider +from pr_agent.git_providers.codecommit_provider import CodeCommitProvider from pr_agent.git_providers.git_provider import GitProvider from pr_agent.git_providers.gitea_provider import GiteaProvider from pr_agent.git_providers.github_provider import GithubProvider from pr_agent.git_providers.gitlab_provider import GitLabProvider +from pr_agent.git_providers.local_git_provider import LocalGitProvider from pr_agent.tools.pr_reviewer import PRReviewer @@ -59,6 +63,54 @@ def _settings(monkeypatch): return settings +def test_normalize_finding_preserves_markdown_while_fingerprint_ignores_rewrapping(): + body = "The loop never ends:\n```text\nwhile attempts < 3:\n attempts += 1\n```" + rewrapped = "The loop never ends: ```text while attempts < 3: attempts += 1 ```" + + normalized = normalize_finding(_finding(body)) + rewrapped_normalized = normalize_finding(_finding(rewrapped)) + + assert normalized["body"] == body + assert normalized["finding_id"] == rewrapped_normalized["finding_id"] + + +def test_resolved_section_preserves_finding_markdown_structure(): + issue = { + "relevant_file": "src/app.py", + "issue_header": "Possible Bug", + "issue_content": "The loop never ends:\n```suggestion\nwhile attempts < 3:\n attempts += 1\n```", + "start_line": 3, + "end_line": 4, + } + finding = PRReviewer._review_finding_from_issue(issue) + active = reconcile_review_findings( + None, + [finding], + allow_resolution=False, + head_sha="head-1", + timestamp="2026-01-01T00:00:00Z", + ).state + resolved = reconcile_review_findings( + active, + [], + allow_resolution=True, + head_sha="head-2", + timestamp="2026-01-01T00:01:00Z", + ).state + + rendered = _render_resolved_section(resolved) + + assert ( + "### src/app.py:3-4\n\n" + "**Possible Issue**\n\n" + "The loop never ends:\n" + "```text\n" + "while attempts < 3:\n" + " attempts += 1\n" + "```" + ) in rendered + + def test_review_finding_state_is_disabled_without_a_provider(monkeypatch): _settings(monkeypatch) @@ -761,7 +813,9 @@ async def test_invalid_history_updates_persistent_comment_without_fallback(monke reviewer._review_state_result = None reviewer._review_state_blocked = True provider.get_issue_comments.return_value = [ - SimpleNamespace(body=f"{PRReviewHeader.REGULAR.value} 🔍\n\nold review\n\n") + SimpleNamespace( + body=f"{PRReviewHeader.REGULAR.value} 🔍\n\nold review\n\n" + ) ] reviewer._load_review_finding_state() @@ -1049,20 +1103,26 @@ async def fake_retry(prepare_fn, model_type=None, git_provider=None): GiteaProvider, BitbucketProvider, BitbucketServerProvider, + CodeCommitProvider, ], - ids=["github", "gitlab", "azure", "gitea", "bitbucket", "bitbucket-server"], + ids=["github", "gitlab", "azure", "gitea", "bitbucket", "bitbucket-server", "codecommit"], ) -def test_legacy_persistent_publish_overrides_accept_shared_arguments(provider_class): +def test_persistent_publish_signatures_accept_shared_arguments(provider_class): parameters = inspect.signature( provider_class.publish_persistent_comment ).parameters + assert "as_thread" in parameters assert "identity_marker" in parameters assert "legacy_initial_header" in parameters assert "require_agent_authorship" not in parameters assert "fallback_on_error" not in parameters +def test_providers_without_override_inherit_persistent_comment_implementation(): + assert LocalGitProvider.publish_persistent_comment is GitProvider.publish_persistent_comment + + def test_oversized_state_degradation_is_safe_on_the_next_run(monkeypatch): settings = _settings(monkeypatch) monkeypatch.setattr(settings.pr_reviewer, "num_max_findings", 100) diff --git a/tests/unittest/test_pr_reviewer_prompt_contract.py b/tests/unittest/test_pr_reviewer_prompt_contract.py index b4d2fdc824..fba8d4ebeb 100644 --- a/tests/unittest/test_pr_reviewer_prompt_contract.py +++ b/tests/unittest/test_pr_reviewer_prompt_contract.py @@ -55,6 +55,42 @@ def _referenced_variables(half): return meta.find_undeclared_variables(environment.parse(template)) +@pytest.mark.parametrize( + ("duplicate_prompt_examples", "expected_user_examples"), + [(False, 0), (True, 1)], +) +def test_ticket_compliance_examples_use_consumer_field( + monkeypatch, + duplicate_prompt_examples, + expected_user_examples, +): + reviewer = _build_reviewer(monkeypatch) + reviewer.vars["related_tickets"] = [ + SimpleNamespace( + ticket_url="https://tracker.example/tickets/1", + title="Ticket", + labels="", + body="", + requirements="", + ) + ] + reviewer.vars["duplicate_prompt_examples"] = duplicate_prompt_examples + + environment = Environment( + autoescape=select_autoescape(default_for_string=False), + undefined=StrictUndefined, + ) + system_prompt = environment.from_string(get_settings().pr_review_prompt.system).render(reviewer.vars) + user_prompt = environment.from_string(get_settings().pr_review_prompt.user).render(reviewer.vars) + + canonical_example = "\n requires_further_human_verification: |" + legacy_example = "\n overall_compliance_level: |" + assert system_prompt.count(canonical_example) == 1 + assert user_prompt.count(canonical_example) == expected_user_examples + assert legacy_example not in system_prompt + assert legacy_example not in user_prompt + + @pytest.mark.parametrize("half", ["system", "user"]) def test_pr_review_prompt_variables_are_all_supplied(monkeypatch, half): reviewer = _build_reviewer(monkeypatch) diff --git a/tests/unittest/test_pr_reviewer_prompt_example_types.py b/tests/unittest/test_pr_reviewer_prompt_example_types.py new file mode 100644 index 0000000000..b22967075b --- /dev/null +++ b/tests/unittest/test_pr_reviewer_prompt_example_types.py @@ -0,0 +1,119 @@ +"""The reviewer prompt's examples must agree with the types its schema declares. + +Named for the example TYPES, not `prompt_contract`: that name is taken by the +Jinja-variable contract tests beside this file, which answer a different +question about the same prompt. + +Issue #3354. The prompts describe the model's output as `class X(BaseModel)` +blocks that exist nowhere as Python, so nothing checks that the schema, the +example and the parser agree — and two fields had drifted in opposite +directions: + + estimated_effort_to_review_[1-5]: int schema + estimated_effort_to_review_[1-5]: | example — a block scalar, i.e. a str + 3 + score: str schema + score: 89 example — a bare int + +Same shape as #3319/#3325 in `pr_add_docs.toml`, and the same test shape: +parse the example the model is shown and hold it to the declared type. This +covers ONE file; #3354's full ask is a real pydantic model per top-level class +with a fixture per tool, which is a larger piece of work. + +Both example blocks are checked, not just the first: they drifted identically, +so a test that read one would go green while the other still taught the wrong +type. +""" + +import re + +import yaml + +from pr_agent.config_loader import get_settings + +# The declared type can be `int`, `List[Foo]` or `Union[List[Foo], str]`, and +# `Field(` may be called positionally, so match up to the `= Field(`. +_DECLARED = re.compile( + r"^\s*(?P[\w\[\]\-]+):\s*(?P[^=]+?)\s*=\s*Field\(", re.M +) + + +def _prompt() -> str: + """Both halves. The schema and the first example live in `system`; the + SECOND example lives in `user`, and they drifted identically -- a test that + read only one would go green while the other still taught the wrong type.""" + prompt = get_settings().pr_review_prompt + return f"{prompt.system}\n{prompt.user}" + + +def _declared_types() -> dict[str, str]: + """field name -> the type its `Field(...)` line declares.""" + return {m.group("field"): m.group("type") for m in _DECLARED.finditer(_prompt())} + + +def _example_blocks() -> list[str]: + """Every ```yaml example in the prompt, Jinja lines stripped. + + The blocks are Jinja-conditional, so the raw text is not valid YAML; the + `{%- ... %}` lines are control flow around the fields, never part of one. + """ + blocks = re.findall(r"```yaml\n(.*?)```", _prompt(), re.S) + return [ + "\n".join(line for line in b.splitlines() if not line.strip().startswith("{%")) + for b in blocks + ] + + +def _review_examples() -> list[dict]: + out = [] + for block in _example_blocks(): + parsed = yaml.safe_load(block) + if isinstance(parsed, dict) and "review" in parsed: + out.append(parsed["review"]) + return out + + +def test_the_prompt_still_carries_the_example_blocks_this_pins(): + """The control: if the prompt stops shipping examples, every assertion + below would pass by having nothing to check.""" + examples = _review_examples() + assert len(examples) == 2, f"expected both review examples, got {len(examples)}" + + +def test_the_effort_example_is_the_int_its_schema_declares(): + assert _declared_types()["estimated_effort_to_review_[1-5]"] == "int" + for example in _review_examples(): + value = example["estimated_effort_to_review_[1-5]"] + assert isinstance(value, int), ( + f"the schema declares int; the example teaches {type(value).__name__} " + f"({value!r}) — a block scalar is a str" + ) + + +def test_the_score_example_is_the_int_its_schema_declares(): + assert _declared_types()["score"] == "int" + for example in _review_examples(): + value = example["score"] + assert isinstance(value, int), ( + f"the schema declares int; the example teaches {type(value).__name__} " + f"({value!r})" + ) + + +def _taught_fields(value) -> set[str]: + """Every key an example teaches, at any depth.""" + if isinstance(value, dict): + return set(value) | {f for v in value.values() for f in _taught_fields(v)} + if isinstance(value, list): + return {f for v in value for f in _taught_fields(v)} + return set() + + +def test_every_field_the_examples_teach_is_one_the_schema_declares(): + """The drift that started #3354 was a field the examples kept after the + schema dropped it (`overall_compliance_level`, #3318). It sat under + `ticket_compliance_check`, so walk nested mappings and lists too.""" + declared = set(_declared_types()) + for example in _review_examples(): + unknown = sorted(_taught_fields(example) - declared) + assert not unknown, f"examples teach fields the schema does not declare: {unknown}" diff --git a/tests/unittest/test_pr_update_changelog.py b/tests/unittest/test_pr_update_changelog.py index b80f15a07f..dc0109b584 100644 --- a/tests/unittest/test_pr_update_changelog.py +++ b/tests/unittest/test_pr_update_changelog.py @@ -1,10 +1,20 @@ -from unittest.mock import AsyncMock, MagicMock, patch +import asyncio +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest +from requests.exceptions import HTTPError, Timeout +from pr_agent.git_providers.github_provider import GithubProvider from pr_agent.tools.pr_update_changelog import PRUpdateChangelog +class _CustomProvider: + """Provider that opts in without inheriting from GithubProvider.""" + + def supports_changelog_update_review(self) -> bool: + return True + + class TestPRUpdateChangelog: """Test suite for the PR Update Changelog functionality.""" @@ -42,7 +52,9 @@ def changelog_tool(self, mock_git_provider, mock_ai_handler): mock_settings.return_value.pr_update_changelog_prompt.user = "User prompt" mock_settings.return_value.config.temperature = 0.2 - tool = PRUpdateChangelog("https://gitlab.com/test/repo/-/merge_requests/1", ai_handler=lambda: mock_ai_handler) + tool = PRUpdateChangelog( + "https://gitlab.com/test/repo/-/merge_requests/1", ai_handler=lambda: mock_ai_handler + ) return tool def test_get_changelog_file_with_existing_content(self, changelog_tool, mock_git_provider): @@ -141,7 +153,7 @@ def test_prepare_changelog_update_no_commit(self, changelog_tool): def _make_no_push_provider(self, extra_spec=None): spec = ["publish_comment", "remove_initial_comment", "get_pr_branch", "get_pr_description", "get_commit_messages", "get_languages", "get_files", "get_pr_file_content", - "is_supported", "pr"] + "is_supported", "supports_changelog_update_review", "pr"] if extra_spec: spec += extra_spec provider = MagicMock(spec=spec) @@ -153,8 +165,281 @@ def _make_no_push_provider(self, extra_spec=None): provider.get_languages.return_value = {"Python": 80, "JavaScript": 20} provider.get_files.return_value = ["test.py", "test.js"] provider.get_pr_file_content.return_value = "" + provider.supports_changelog_update_review.return_value = False + return provider + + def _make_push_provider(self): + provider = self._make_no_push_provider(extra_spec=["create_or_update_pr_file"]) + provider.is_supported.return_value = True return provider + @staticmethod + def _configure_settings(mock_settings, publish_output=True): + settings = mock_settings.return_value + settings.pr_update_changelog.push_changelog_changes = True + settings.pr_update_changelog.extra_instructions = "" + settings.pr_update_changelog_prompt.system = "" + settings.pr_update_changelog_prompt.user = "" + settings.config.publish_output = publish_output + settings.config.temperature = 0.2 + settings.get.return_value = {} + + @pytest.mark.asyncio + async def test_strict_read_error_generates_one_fallback_never_writes_and_reraises_original( + self, mock_ai_handler): + provider = self._make_push_provider() + read_error = RuntimeError("read unavailable") + provider.get_pr_file_content.side_effect = read_error + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models") as retry, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + tool.prediction = "## v1.1.0\n- Safe generated entry" + + with pytest.raises(RuntimeError) as exc_info: + await tool.run() + + assert exc_info.value is read_error + provider.get_pr_file_content.assert_called_once_with( + "CHANGELOG.md", "feature-branch", propagate_errors=True + ) + provider.create_or_update_pr_file.assert_not_called() + assert retry.await_count == 1 + fallback_calls = [ + call for call in provider.publish_comment.call_args_list + if "not pushed" in call.args[0] + ] + assert len(fallback_calls) == 1 + assert "Safe generated entry" in fallback_calls[0].args[0] + provider.remove_initial_comment.assert_called_once_with() + + @pytest.mark.asyncio + async def test_strict_read_error_skips_configuration_rendering(self, mock_ai_handler): + provider = self._make_push_provider() + read_error = RuntimeError("read unavailable") + provider.get_pr_file_content.side_effect = read_error + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models"), \ + patch("pr_agent.tools.pr_update_changelog.show_relevant_configurations", + side_effect=TypeError("invalid skip_keys")) as render_config, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + mock_settings.return_value.get.return_value = {"output_relevant_configurations": True} + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + tool.prediction = "## v1.1.0\n- Safe generated entry" + + with pytest.raises(RuntimeError) as exc_info: + await tool.run() + + assert exc_info.value is read_error + render_config.assert_not_called() + provider.create_or_update_pr_file.assert_not_called() + fallback_calls = [ + call for call in provider.publish_comment.call_args_list + if "not pushed" in call.args[0] + ] + assert len(fallback_calls) == 1 + assert "Safe generated entry" in fallback_calls[0].args[0] + provider.remove_initial_comment.assert_called_once_with() + + def test_strict_read_setup_failure_attempts_fallback_and_reraises_original(self): + provider = self._make_push_provider() + read_error = RuntimeError("read unavailable") + setup_error = RuntimeError("handler unavailable") + provider.get_pr_file_content.side_effect = read_error + handler_factory = MagicMock(side_effect=setup_error) + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.get_logger") as mock_get_logger, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + + with pytest.raises(RuntimeError) as exc_info: + PRUpdateChangelog("https://example.com/pr/1", ai_handler=handler_factory) + + assert exc_info.value is read_error + handler_factory.assert_called_once_with() + provider.create_or_update_pr_file.assert_not_called() + fallback_calls = [ + call for call in provider.publish_comment.call_args_list + if "not pushed" in call.args[0] + ] + assert len(fallback_calls) == 1 + assert "could not be generated" in fallback_calls[0].args[0] + mock_get_logger.return_value.exception.assert_called_once_with( + "Failed to initialize changelog generation after a read error: handler unavailable" + ) + + @pytest.mark.asyncio + async def test_strict_read_fallback_failure_does_not_mask_original(self, mock_ai_handler): + provider = self._make_push_provider() + read_error = RuntimeError("read unavailable") + fallback_error = RuntimeError("comment unavailable") + provider.get_pr_file_content.side_effect = read_error + + def publish_comment(_body, is_temporary=False): + if not is_temporary: + raise fallback_error + + provider.publish_comment.side_effect = publish_comment + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models"), \ + patch("pr_agent.tools.pr_update_changelog.get_logger") as mock_get_logger, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + tool.prediction = "## v1.1.0\n- Safe generated entry" + + with pytest.raises(RuntimeError) as exc_info: + await tool.run() + + assert exc_info.value is read_error + provider.create_or_update_pr_file.assert_not_called() + mock_get_logger.return_value.exception.assert_called_once_with( + "Failed to publish changelog fallback after a read error: comment unavailable" + ) + + @pytest.mark.asyncio + async def test_strict_read_progress_failure_still_generates_fallback_and_reraises_original( + self, mock_ai_handler): + provider = self._make_push_provider() + read_error = RuntimeError("read unavailable") + progress_error = RuntimeError("progress unavailable") + provider.get_pr_file_content.side_effect = read_error + + def publish_comment(_body, is_temporary=False): + if is_temporary: + raise progress_error + + provider.publish_comment.side_effect = publish_comment + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models") as retry, \ + patch("pr_agent.tools.pr_update_changelog.get_logger") as mock_get_logger, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + tool.prediction = "## v1.1.0\n- Safe generated entry" + + with pytest.raises(RuntimeError) as exc_info: + await tool.run() + + assert exc_info.value is read_error + assert retry.await_count == 1 + provider.create_or_update_pr_file.assert_not_called() + fallback_calls = [ + call for call in provider.publish_comment.call_args_list + if "not pushed" in call.args[0] + ] + assert len(fallback_calls) == 1 + assert "Safe generated entry" in fallback_calls[0].args[0] + provider.remove_initial_comment.assert_not_called() + mock_get_logger.return_value.exception.assert_called_once_with( + "Failed to publish changelog progress after a read error: progress unavailable" + ) + + @pytest.mark.asyncio + async def test_strict_read_generation_failure_attempts_fallback_and_reraises_original( + self, mock_ai_handler): + provider = self._make_push_provider() + read_error = RuntimeError("read unavailable") + generation_error = RuntimeError("generation unavailable") + provider.get_pr_file_content.side_effect = read_error + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models", + side_effect=generation_error) as retry, \ + patch("pr_agent.tools.pr_update_changelog.get_logger") as mock_get_logger, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + + with pytest.raises(RuntimeError) as exc_info: + await tool.run() + + assert exc_info.value is read_error + assert retry.await_count == 1 + provider.create_or_update_pr_file.assert_not_called() + fallback_calls = [ + call for call in provider.publish_comment.call_args_list + if "not pushed" in call.args[0] + ] + assert len(fallback_calls) == 1 + assert "could not be generated" in fallback_calls[0].args[0] + provider.remove_initial_comment.assert_called_once_with() + mock_get_logger.return_value.exception.assert_called_once_with( + "Failed to generate changelog fallback after a read error: generation unavailable" + ) + + def test_custom_provider_without_strict_keyword_fails_closed_without_retry(self, mock_ai_handler): + provider = self._make_push_provider() + + def legacy_getter(_file_path, _branch): + return "existing content" + + provider.get_pr_file_content.side_effect = legacy_getter + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + + assert isinstance(tool.changelog_read_error, TypeError) + assert provider.get_pr_file_content.call_count == 1 + provider.get_pr_file_content.assert_called_once_with( + "CHANGELOG.md", "feature-branch", propagate_errors=True + ) + + @pytest.mark.parametrize("content", ["", "# Changelog\n\n## v1.0.0\n- Existing entry"]) + def test_strict_read_accepts_successful_empty_and_nonempty_content(self, mock_ai_handler, content): + provider = self._make_push_provider() + provider.get_pr_file_content.return_value = content + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + + assert tool.changelog_read_error is None + assert tool.changelog_file == content + provider.get_pr_file_content.assert_called_once_with( + "CHANGELOG.md", "feature-branch", propagate_errors=True + ) + + @pytest.mark.asyncio + async def test_output_disabled_keeps_lenient_read_and_never_writes(self, mock_ai_handler): + provider = self._make_push_provider() + provider.get_pr_file_content.side_effect = RuntimeError("read unavailable") + + with patch("pr_agent.tools.pr_update_changelog.get_git_provider", return_value=lambda url: provider), \ + patch("pr_agent.tools.pr_update_changelog.get_main_pr_language", return_value="Python"), \ + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models") as retry, \ + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings: + self._configure_settings(mock_settings, publish_output=False) + tool = PRUpdateChangelog("https://example.com/pr/1", ai_handler=lambda: mock_ai_handler) + tool.prediction = "## v1.1.0\n- Safe generated entry" + + await tool.run() + + assert tool.changelog_read_error is None + assert retry.await_count == 1 + provider.get_pr_file_content.assert_called_once_with("CHANGELOG.md", "feature-branch") + provider.create_or_update_pr_file.assert_not_called() + provider.publish_comment.assert_not_called() + provider.remove_initial_comment.assert_not_called() + @pytest.mark.asyncio async def test_run_without_push_support(self, mock_ai_handler): """When the provider can't push (no create_or_update_pr_file), the changelog must still @@ -226,7 +511,7 @@ async def test_run_with_push_support(self, changelog_tool, mock_git_provider): with patch('pr_agent.tools.pr_update_changelog.get_settings') as mock_settings, \ patch('pr_agent.tools.pr_update_changelog.retry_with_fallback_models') as mock_retry, \ - patch('pr_agent.tools.pr_update_changelog.sleep'): + patch('pr_agent.tools.pr_update_changelog.asyncio.sleep', new_callable=AsyncMock) as sleep: mock_settings.return_value.pr_update_changelog.push_changelog_changes = True mock_settings.return_value.pr_update_changelog.get.return_value = True @@ -242,8 +527,366 @@ async def test_run_with_push_support(self, changelog_tool, mock_git_provider): call_args = mock_git_provider.create_or_update_pr_file.call_args assert call_args[1]['file_path'] == 'CHANGELOG.md' assert call_args[1]['branch'] == 'feature-branch' + sleep.assert_awaited_once_with(5) - def test_push_changelog_update(self, changelog_tool, mock_git_provider): + @pytest.mark.asyncio + async def test_push_changelog_update_creates_review_when_supported(self, changelog_tool, mock_git_provider): + """When supported, pushing the changelog creates a PR review on the committed changes.""" + mock_git_provider.create_or_update_pr_file = MagicMock(return_value="commit-123") + mock_git_provider.get_pr_branch.return_value = "feature-branch" + mock_git_provider.supports_changelog_update_review.return_value = True + mock_git_provider.pr.get_commits.return_value = ["commit-123"] + mock_git_provider.pr.create_review = MagicMock() + new_content = "# Updated changelog content" + answer = "Line 1\nLine 2" + + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", new_callable=AsyncMock + ) as sleep: + mock_settings.return_value.pr_update_changelog.get.return_value = True + + await changelog_tool._push_changelog_update(new_content, answer) + + mock_git_provider.create_or_update_pr_file.assert_called_once_with( + file_path="CHANGELOG.md", + branch="feature-branch", + contents=new_content, + message="[skip ci] Update CHANGELOG.md", + ) + mock_git_provider.pr.create_review.assert_called_once_with( + commit="commit-123", + event="COMMENT", + comments=[ + dict( + body="CHANGELOG.md update", + path="CHANGELOG.md", + line=2, + start_line=1, + ) + ], + ) + mock_git_provider.publish_comment.assert_not_called() + mock_git_provider.pr.get_commits.assert_not_called() + sleep.assert_awaited_once_with(5) + + @pytest.mark.asyncio + async def test_push_changelog_update_yields_during_wait_before_review( + self, changelog_tool, mock_git_provider + ): + events = [] + peer_progressed = asyncio.Event() + mock_git_provider.create_or_update_pr_file.side_effect = ( + lambda **_kwargs: events.append("write") or "commit-123" + ) + mock_git_provider.supports_changelog_update_review.return_value = True + mock_git_provider.pr.create_review.side_effect = lambda **_kwargs: events.append("review") + + async def peer_work(): + events.append("peer progress") + peer_progressed.set() + + async def cooperative_wait(delay): + assert delay == 5 + events.append("wait") + await peer_progressed.wait() + + peer_task = asyncio.create_task(peer_work()) + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", side_effect=cooperative_wait + ) as sleep: + mock_settings.return_value.pr_update_changelog.get.return_value = True + + await changelog_tool._push_changelog_update("new content", "answer") + + assert await peer_task is None + sleep.assert_awaited_once_with(5) + mock_git_provider.pr.get_commits.assert_not_called() + assert events == ["write", "wait", "peer progress", "review"] + + @pytest.mark.asyncio + async def test_push_changelog_update_reviews_exact_written_commit_when_branch_advances( + self, changelog_tool, mock_git_provider + ): + written_commit = object() + branch_advanced = asyncio.Event() + mock_git_provider.create_or_update_pr_file.return_value = written_commit + mock_git_provider.supports_changelog_update_review.return_value = True + mock_git_provider.pr.get_commits.return_value = [object()] + + async def cooperative_wait(delay): + assert delay == 5 + branch_advanced.set() + + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", side_effect=cooperative_wait + ): + mock_settings.return_value.pr_update_changelog.get.return_value = True + + await changelog_tool._push_changelog_update("new content", "answer") + + assert branch_advanced.is_set() + mock_git_provider.pr.get_commits.assert_not_called() + assert mock_git_provider.pr.create_review.call_args.kwargs["commit"] is written_commit + + @pytest.mark.asyncio + async def test_push_changelog_update_cancellation_after_write_publishes_fallback_and_propagates( + self, changelog_tool, mock_git_provider + ): + entered_wait = asyncio.Event() + mock_git_provider.create_or_update_pr_file.return_value = object() + mock_git_provider.supports_changelog_update_review.return_value = True + + async def pending_wait(delay): + assert delay == 5 + entered_wait.set() + await asyncio.Event().wait() + + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", side_effect=pending_wait + ): + mock_settings.return_value.pr_update_changelog.get.return_value = True + update_task = asyncio.create_task( + changelog_tool._push_changelog_update("new content", "answer") + ) + await entered_wait.wait() + update_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await asyncio.gather(update_task) + + mock_git_provider.publish_comment.assert_called_once_with( + "**Changelog updates: 🔄**\n\nanswer" + ) + mock_git_provider.pr.get_commits.assert_not_called() + mock_git_provider.pr.create_review.assert_not_called() + + @pytest.mark.asyncio + async def test_run_cancellation_after_write_publishes_fallback_before_cleanup( + self, changelog_tool, mock_git_provider + ): + entered_wait = asyncio.Event() + mock_git_provider.create_or_update_pr_file.return_value = object() + mock_git_provider.supports_changelog_update_review.return_value = True + changelog_tool.commit_changelog = True + changelog_tool.prediction = "## v1.1.0\n- New feature" + + async def pending_wait(_delay): + entered_wait.set() + await asyncio.Event().wait() + + with ( + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models"), + patch("pr_agent.tools.pr_update_changelog.asyncio.sleep", side_effect=pending_wait), + ): + mock_settings.return_value.config.publish_output = True + mock_settings.return_value.pr_update_changelog.get.return_value = True + mock_settings.return_value.get.return_value = {} + run_task = asyncio.create_task(changelog_tool.run()) + await entered_wait.wait() + run_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await asyncio.gather(run_task) + + assert mock_git_provider.publish_comment.call_args_list == [ + call("Preparing changelog updates...", is_temporary=True), + call("**Changelog updates: 🔄**\n\n## v1.1.0\n- New feature"), + ] + mock_git_provider.pr.create_review.assert_not_called() + mock_git_provider.remove_initial_comment.assert_called_once_with() + + @pytest.mark.asyncio + async def test_push_changelog_update_cancellation_fallback_failure_does_not_mask_cancellation( + self, changelog_tool, mock_git_provider + ): + entered_wait = asyncio.Event() + feedback_error = RuntimeError("comment unavailable") + mock_git_provider.create_or_update_pr_file.return_value = object() + mock_git_provider.supports_changelog_update_review.return_value = True + mock_git_provider.publish_comment.side_effect = feedback_error + + async def pending_wait(_delay): + entered_wait.set() + await asyncio.Event().wait() + + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", side_effect=pending_wait + ), patch("pr_agent.tools.pr_update_changelog.get_logger") as mock_get_logger: + mock_settings.return_value.pr_update_changelog.get.return_value = True + update_task = asyncio.create_task( + changelog_tool._push_changelog_update("new content", "answer") + ) + await entered_wait.wait() + update_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await asyncio.gather(update_task) + + mock_get_logger.return_value.exception.assert_called_once_with( + "Failed to publish changelog fallback during cancellation: comment unavailable" + ) + mock_git_provider.pr.create_review.assert_not_called() + + @pytest.mark.parametrize("error_type", [HTTPError, Timeout]) + @pytest.mark.asyncio + async def test_push_changelog_update_retains_output_and_stops_success_follow_up_after_write_failure( + self, changelog_tool, mock_git_provider, error_type + ): + write_error = error_type("write failed") + mock_git_provider.create_or_update_pr_file.side_effect = write_error + mock_git_provider.get_pr_branch.return_value = "feature-branch" + mock_git_provider.supports_changelog_update_review.return_value = True + + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", new_callable=AsyncMock + ) as sleep: + mock_settings.return_value.pr_update_changelog.get.return_value = True + + with pytest.raises(error_type) as raised: + await changelog_tool._push_changelog_update("new content", "answer") + + assert raised.value is write_error + mock_git_provider.create_or_update_pr_file.assert_called_once() + sleep.assert_not_awaited() + mock_git_provider.pr.get_commits.assert_not_called() + mock_git_provider.pr.create_review.assert_not_called() + mock_git_provider.publish_comment.assert_called_once() + fallback = mock_git_provider.publish_comment.call_args.args[0] + assert "answer" in fallback + assert "could not be confirmed" in fallback + assert "not pushed" not in fallback + + @pytest.mark.asyncio + async def test_push_changelog_update_fallback_failure_does_not_mask_write_error( + self, changelog_tool, mock_git_provider + ): + write_error = Timeout("write outcome unknown") + fallback_error = RuntimeError("comment unavailable") + mock_git_provider.create_or_update_pr_file.side_effect = write_error + mock_git_provider.publish_comment.side_effect = fallback_error + + with ( + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, + patch("pr_agent.tools.pr_update_changelog.get_logger") as mock_get_logger, + pytest.raises(Timeout) as raised, + ): + mock_settings.return_value.pr_update_changelog.get.return_value = True + await changelog_tool._push_changelog_update("new content", "answer") + + assert raised.value is write_error + mock_git_provider.publish_comment.assert_called_once() + mock_get_logger.return_value.exception.assert_called_once_with( + "Failed to publish changelog fallback after a write error: comment unavailable" + ) + + @pytest.mark.asyncio + async def test_run_preserves_write_failure_after_temporary_comment_cleanup( + self, changelog_tool, mock_git_provider + ): + write_error = HTTPError("403 Client Error") + mock_git_provider.create_or_update_pr_file.side_effect = write_error + mock_git_provider.remove_initial_comment.side_effect = RuntimeError("cleanup failed") + mock_git_provider.supports_changelog_update_review.return_value = True + changelog_tool.commit_changelog = True + changelog_tool.prediction = "## v1.1.0\n- New feature" + + with ( + patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, + patch("pr_agent.tools.pr_update_changelog.retry_with_fallback_models"), + patch("pr_agent.tools.pr_update_changelog.asyncio.sleep", new_callable=AsyncMock) as sleep, + ): + mock_settings.return_value.config.publish_output = True + mock_settings.return_value.pr_update_changelog.get.return_value = True + mock_settings.return_value.get.return_value = {} + + with pytest.raises(HTTPError) as raised: + await changelog_tool.run() + + assert raised.value is write_error + assert mock_git_provider.publish_comment.call_args_list == [ + call("Preparing changelog updates...", is_temporary=True), + call( + "**Changelog updates:** 🔄\n\n## v1.1.0\n- New feature" + "\n\n> ⚠️ The repository update could not be confirmed. " + "The generated changelog is preserved here for recovery." + ), + ] + mock_git_provider.remove_initial_comment.assert_called_once_with() + sleep.assert_not_awaited() + mock_git_provider.pr.get_commits.assert_not_called() + mock_git_provider.pr.create_review.assert_not_called() + + @pytest.mark.asyncio + async def test_push_changelog_update_skips_review_when_not_supported(self, changelog_tool, mock_git_provider): + """A provider without the capability is never asked for a commit-scoped review.""" + mock_git_provider.create_or_update_pr_file = MagicMock() + mock_git_provider.get_pr_branch.return_value = "feature-branch" + mock_git_provider.supports_changelog_update_review.return_value = False + new_content = "# Updated changelog content" + answer = "Changes made" + + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", new_callable=AsyncMock + ) as sleep: + mock_settings.return_value.pr_update_changelog.get.return_value = True + + await changelog_tool._push_changelog_update(new_content, answer) + + mock_git_provider.create_or_update_pr_file.assert_called_once_with( + file_path="CHANGELOG.md", + branch="feature-branch", + contents=new_content, + message="[skip ci] Update CHANGELOG.md", + ) + mock_git_provider.pr.get_commits.assert_not_called() + mock_git_provider.publish_comment.assert_not_called() + sleep.assert_awaited_once_with(5) + + @pytest.mark.asyncio + async def test_push_changelog_update_falls_back_to_comment_when_written_commit_is_unavailable( + self, changelog_tool, mock_git_provider + ): + """Do not guess the commit when a review-capable write returns none.""" + mock_git_provider.create_or_update_pr_file = MagicMock(return_value=None) + mock_git_provider.get_pr_branch.return_value = "feature-branch" + mock_git_provider.supports_changelog_update_review.return_value = True + new_content = "# Updated changelog content" + answer = "Changes made" + + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", new_callable=AsyncMock + ): + mock_settings.return_value.pr_update_changelog.get.return_value = True + + await changelog_tool._push_changelog_update(new_content, answer) + + mock_git_provider.pr.get_commits.assert_not_called() + mock_git_provider.publish_comment.assert_called_once_with(f"**Changelog updates: 🔄**\n\n{answer}") + + @pytest.mark.asyncio + async def test_push_changelog_update_falls_back_to_comment_on_review_exception( + self, changelog_tool, mock_git_provider + ): + """When creating a review raises an exception, it falls back to publishing a comment.""" + mock_git_provider.create_or_update_pr_file = MagicMock(return_value="commit-123") + mock_git_provider.get_pr_branch.return_value = "feature-branch" + mock_git_provider.supports_changelog_update_review.return_value = True + mock_git_provider.pr.create_review.side_effect = Exception("API error") + answer = "Changes made" + + with patch("pr_agent.tools.pr_update_changelog.get_settings") as mock_settings, patch( + "pr_agent.tools.pr_update_changelog.asyncio.sleep", new_callable=AsyncMock + ): + mock_settings.return_value.pr_update_changelog.get.return_value = True + + await changelog_tool._push_changelog_update("new content", answer) + + mock_git_provider.pr.get_commits.assert_not_called() + mock_git_provider.publish_comment.assert_called_once_with(f"**Changelog updates: 🔄**\n\n{answer}") + + @pytest.mark.asyncio + async def test_push_changelog_update(self, changelog_tool, mock_git_provider): """Test the push changelog update functionality.""" # Arrange mock_git_provider.create_or_update_pr_file = MagicMock() @@ -252,12 +895,12 @@ def test_push_changelog_update(self, changelog_tool, mock_git_provider): answer = "Changes made" with patch('pr_agent.tools.pr_update_changelog.get_settings') as mock_settings, \ - patch('pr_agent.tools.pr_update_changelog.sleep'): + patch('pr_agent.tools.pr_update_changelog.asyncio.sleep', new_callable=AsyncMock): mock_settings.return_value.pr_update_changelog.get.return_value = True # Act - changelog_tool._push_changelog_update(new_content, answer) + await changelog_tool._push_changelog_update(new_content, answer) # Assert mock_git_provider.create_or_update_pr_file.assert_called_once_with( @@ -267,6 +910,21 @@ def test_push_changelog_update(self, changelog_tool, mock_git_provider): message="[skip ci] Update CHANGELOG.md" ) + @pytest.mark.asyncio + async def test_push_changelog_update_never_calls_create_or_update_pr_file_when_push_code_is_unsupported( + self, changelog_tool, mock_git_provider): + """A provider that declines `push_code` (e.g. restricted_mode) must never reach + `create_or_update_pr_file`, even if a future caller invokes this method directly + without going through `run()`'s own `self.commit_changelog` gate.""" + mock_git_provider.create_or_update_pr_file = MagicMock() + mock_git_provider.is_supported.return_value = False + new_content = "# Updated changelog content" + answer = "Changes made" + + await changelog_tool._push_changelog_update(new_content, answer) + + mock_git_provider.create_or_update_pr_file.assert_not_called() + def test_gitlab_provider_method_detection(self, changelog_tool, mock_git_provider): """Test that the tool correctly detects GitLab provider method availability.""" # Arrange @@ -275,6 +933,18 @@ def test_gitlab_provider_method_detection(self, changelog_tool, mock_git_provide # Act & Assert assert hasattr(mock_git_provider, "create_or_update_pr_file") + @pytest.mark.parametrize( + "provider_class, expected", + [(GithubProvider, True), (_CustomProvider, True), (MagicMock, False)], + ) + def test_supports_changelog_update_review_follows_provider_capability(self, provider_class, expected): + if provider_class is MagicMock: + provider = MagicMock() + provider.supports_changelog_update_review.return_value = False + else: + provider = provider_class.__new__(provider_class) + assert provider.supports_changelog_update_review() is expected + @pytest.mark.parametrize("existing_content,new_entry,expected_order", [ ( "# Changelog\n\n## v1.0.0\n- Old feature", diff --git a/tests/unittest/test_progress_comment.py b/tests/unittest/test_progress_comment.py index e4be03a337..e8542b134d 100644 --- a/tests/unittest/test_progress_comment.py +++ b/tests/unittest/test_progress_comment.py @@ -86,4 +86,7 @@ def test_build_progress_comment_uses_defaults(mock_get_settings): progress_comment = build_progress_comment() - assert f'Work in progress' in progress_comment + assert ( + f'Work in progress' + in progress_comment + ) diff --git a/tests/unittest/test_prompt_fit_template_reuse.py b/tests/unittest/test_prompt_fit_template_reuse.py new file mode 100644 index 0000000000..abc04e0f63 --- /dev/null +++ b/tests/unittest/test_prompt_fit_template_reuse.py @@ -0,0 +1,77 @@ +from types import SimpleNamespace + +import pytest +from jinja2 import UndefinedError +from jinja2.sandbox import SecurityError + +from pr_agent.algo.token_budget import AttemptTokenBudget, SandboxedEnvironment + + +@pytest.mark.parametrize("optional_text", ["short <&> diff", "line <&>\n" * 100]) +@pytest.mark.parametrize("keep", ["prefix", "suffix"]) +def test_prompt_fitting_compiles_templates_once(monkeypatch, optional_text, keep): + handler = SimpleNamespace( + system="Review {{ title }}: <&>", + user="{{ title }}\n{{ diff }}", + count_tokens=len, + ) + budget = AttemptTokenBudget("test-model", handler, handler, 200) + variables = {"title": "PR <&>", "diff": "original"} + compiled = [] + from_string = SandboxedEnvironment.from_string + + def record_compile(self, source, *args, **kwargs): + compiled.append(source) + return from_string(self, source, *args, **kwargs) + + monkeypatch.setattr(SandboxedEnvironment, "from_string", record_compile) + fitted = budget.fit_prompt_variable( + variables, "diff", optional_text, ai_handler=object(), default_output_tokens=10, keep=keep, + ) + + assert compiled == [handler.system, handler.user] + assert variables == {"title": "PR <&>", "diff": "original"} + assert fitted.input_tokens <= 190 + assert fitted.system_prompt == "Review PR <&>: <&>" + assert fitted.user_prompt == f"PR <&>\n{fitted.optional_text}" + if len(optional_text) > 200: + assert fitted.optional_text != optional_text + else: + assert fitted.optional_text == optional_text + + def render_uncached(candidate): + return budget.render_prompt_templates({**variables, "diff": candidate}) + + assert fitted == budget.fit_optional_text( + optional_text, render_uncached, ai_handler=object(), default_output_tokens=10, keep=keep, + ) + + +def test_prompt_fitting_does_not_reuse_templates_or_variables_between_calls(): + handler = SimpleNamespace(system="{{ title }}", user="{{ diff }}", count_tokens=len) + budget = AttemptTokenBudget("test-model", handler, handler, 200) + first = budget.fit_prompt_variable( + {"title": "first"}, "diff", "one", ai_handler=object(), default_output_tokens=10, + ) + handler.system = "updated {{ title }}" + handler.user = "updated {{ diff }}" + second = budget.fit_prompt_variable( + {"title": "second"}, "diff", "two", ai_handler=object(), default_output_tokens=10, + ) + + assert (first.system_prompt, first.user_prompt) == ("first", "one") + assert (second.system_prompt, second.user_prompt) == ("updated second", "updated two") + + +@pytest.mark.parametrize( + ("template", "error"), + [("{{ missing }}", UndefinedError), ("{{ value.__class__ }}", SecurityError)], +) +def test_prompt_fitting_preserves_strict_sandbox_rendering(template, error): + handler = SimpleNamespace(system=template, user="{{ diff }}", count_tokens=len) + budget = AttemptTokenBudget("test-model", handler, handler, 200) + + with pytest.raises(error): + budget.fit_prompt_variable( + {"value": object()}, "diff", "one", ai_handler=object(), default_output_tokens=10, + ) diff --git a/tests/unittest/test_prompt_fragments.py b/tests/unittest/test_prompt_fragments.py index aa1b0efca7..cfa51351ba 100644 --- a/tests/unittest/test_prompt_fragments.py +++ b/tests/unittest/test_prompt_fragments.py @@ -106,6 +106,36 @@ def test_affected_templates_delegate_to_shared_fragment_once(prompt_name, legacy assert legacy_example not in system_prompt +@pytest.mark.parametrize("prompt_name", [ + "pr_code_suggestions_prompt", + "pr_code_suggestions_prompt_not_decoupled", +]) +def test_suggestion_prompts_allow_dependencies_introduced_by_improved_code(prompt_name): + prompt = get_settings().get(prompt_name).system + variables = { + "focus_only_on_problems": True, + "num_code_suggestions": 3, + "diff_hunk_format": "diff", + "skills_context": "", + "extra_instructions": "", + "repo_context": "", + } + + rendered = Environment().from_string(prompt).render(variables) + + assert "If the suggested `improved_code` introduces a dependency" in rendered + assert "standalone or unrelated missing" in rendered + + +def test_reflection_prompt_does_not_zero_required_imports(): + prompt = get_settings().pr_code_suggestions_reflect_prompt.system + + rendered = Environment().from_string(prompt).render({"diff_hunk_format": "diff"}) + + assert "- Add standalone or unrelated missing import statements" in rendered + assert "- Add missing import statements" not in rendered + + def test_fragment_renderer_sandboxes_host_overrides(restore_prompt_settings): get_settings().set( "prompt_fragments.diff_hunk_format", @@ -196,6 +226,7 @@ def test_non_decoupled_suggestions_render_without_the_shared_fragment(monkeypatc @pytest.mark.parametrize("include_ai_metadata", [False, True]) +@pytest.mark.asyncio async def test_reflection_supplies_the_numbered_fragment( include_ai_metadata, restore_prompt_settings, @@ -210,7 +241,7 @@ async def test_reflection_supplies_the_numbered_fragment( result = await tool.self_reflect_on_suggestions( [{"one_sentence_summary": "Keep the shared prompt accurate"}], diff, - "test-model", + settings.config.model, ) call = ai_handler.chat_completion.await_args.kwargs diff --git a/tests/unittest/test_publish_workflow_security.py b/tests/unittest/test_publish_workflow_security.py index 5cdd696f0b..b97fb46e9f 100644 --- a/tests/unittest/test_publish_workflow_security.py +++ b/tests/unittest/test_publish_workflow_security.py @@ -185,3 +185,19 @@ def test_release_version_cannot_inject_workflow_outputs(tmp_path: Path, line_bre assert result.returncode != 0 assert output.read_text() == "existing-output\n" + + +def test_release_finalize_keeps_uv_lock_in_sync() -> None: + workflow = yaml.safe_load(PUBLISH_WORKFLOW.read_text()) + finalize_steps = workflow["jobs"]["finalize"]["steps"] + bump_step = next( + step for step in finalize_steps if step.get("name") == "Bump pyproject.toml and sync uv.lock" + ) + commit_step = next( + step for step in finalize_steps if step.get("name") == "Commit & push bump (fast-forward only)" + ) + + run = bump_step["run"] + assert run.index("scripts/set_pyproject_version.py") < run.index("uv lock") + assert "git diff --quiet pyproject.toml uv.lock" in commit_step["run"] + assert "git add pyproject.toml uv.lock" in commit_step["run"] diff --git a/tests/unittest/test_push_outputs.py b/tests/unittest/test_push_outputs.py index dac6d1103e..882662bed6 100644 --- a/tests/unittest/test_push_outputs.py +++ b/tests/unittest/test_push_outputs.py @@ -1,9 +1,11 @@ import json +from types import SimpleNamespace import pytest -from pr_agent.algo import utils -from pr_agent.algo.utils import get_settings, push_outputs +from pr_agent.algo import run_output +from pr_agent.algo.run_output import push_outputs +from pr_agent.config_loader import get_settings @pytest.fixture(autouse=True) @@ -14,6 +16,7 @@ def _reset_push_outputs(): s = get_settings() s.set('PUSH_OUTPUTS.ENABLE', False) s.set('PUSH_OUTPUTS.CHANNELS', []) + s.set('PUSH_OUTPUTS.FILE_PATH', 'pr-agent-outputs/reviews.jsonl') s.set('PUSH_OUTPUTS.WEBHOOK_URL', '') s.set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', '') @@ -65,8 +68,9 @@ def test_slack_channel_posts_text(self, monkeypatch): def fake_post(url, json=None, timeout=None, **kwargs): captured['url'] = url captured['json'] = json + return SimpleNamespace(status_code=200) - monkeypatch.setattr(utils.requests, 'post', fake_post) + monkeypatch.setattr(run_output.requests, 'post', fake_post) push_outputs("review", payload={"a": 1}, markdown="a markdown review") @@ -103,12 +107,12 @@ def get_repo_settings(self): def test_errors_are_non_fatal(self, monkeypatch): get_settings().set('PUSH_OUTPUTS.ENABLE', True) get_settings().set('PUSH_OUTPUTS.CHANNELS', ['webhook']) - get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'http://example.invalid/hook') + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://example.invalid/hook') def boom(*args, **kwargs): raise ConnectionError("no network") - monkeypatch.setattr(utils.requests, 'post', boom) + monkeypatch.setattr(run_output.requests, 'post', boom) # Must not raise. push_outputs("review", payload={"a": 1}, markdown="hi") @@ -127,8 +131,8 @@ def test_non_https_sink_urls_are_ignored(self, monkeypatch, bad_url): get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', bad_url) posts = [] - monkeypatch.setattr(utils.requests, 'post', - lambda url, **kwargs: posts.append(url)) + monkeypatch.setattr(run_output.requests, 'post', + lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) push_outputs("review", payload={"a": 1}, markdown="hi") @@ -140,9 +144,211 @@ def test_https_sink_url_is_accepted(self, monkeypatch): get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://example.test/hook') posts = [] - monkeypatch.setattr(utils.requests, 'post', - lambda url, **kwargs: posts.append(url)) + monkeypatch.setattr(run_output.requests, 'post', + lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) push_outputs("review", payload={"a": 1}, markdown="hi") assert posts == ['https://example.test/hook'] + + def test_setup_errors_remain_non_fatal_and_secret_safe(self, monkeypatch): + warnings = [] + + def fail_settings(): + raise RuntimeError("secret setup marker") + + monkeypatch.setattr(run_output, 'get_settings', fail_settings) + monkeypatch.setattr(run_output, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"payload-secret": 1}, markdown="markdown-secret") + + assert warnings == ["push_outputs failed: RuntimeError"] + assert "secret" not in warnings[0] + + def test_webhook_exception_does_not_skip_slack(self, monkeypatch): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['webhook', 'slack']) + webhook_url = 'https://example.test/webhook-secret' + slack_url = 'https://example.test/slack-secret' + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', webhook_url) + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', slack_url) + posts = [] + warnings = [] + + def fake_post(url, **kwargs): + posts.append((url, kwargs)) + if url == webhook_url: + raise ConnectionError("transport-secret") + return SimpleNamespace(status_code=200) + + monkeypatch.setattr(run_output.requests, 'post', fake_post) + monkeypatch.setattr(run_output, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"payload-secret": 1}, markdown="markdown-secret") + + assert [url for url, _ in posts] == [webhook_url, slack_url] + assert posts[0][1]['timeout'] == 5 + assert posts[0][1]['allow_redirects'] is False + assert posts[1][1]['timeout'] == 5 + assert posts[1][1]['allow_redirects'] is False + assert warnings == ["push_outputs: webhook failed: ConnectionError"] + assert not any(secret in warnings[0] for secret in + (webhook_url, slack_url, "transport-secret", "payload-secret", "markdown-secret")) + + @pytest.mark.parametrize("status_code", [302, 500]) + def test_webhook_non_2xx_warns_and_does_not_skip_slack(self, monkeypatch, status_code): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['webhook', 'slack']) + webhook_url = 'https://example.test/webhook' + slack_url = 'https://example.test/slack' + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', webhook_url) + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', slack_url) + posts = [] + warnings = [] + + def fake_post(url, **kwargs): + posts.append(url) + if url == webhook_url: + return SimpleNamespace(status_code=status_code, text="response-secret") + return SimpleNamespace(status_code=204) + + monkeypatch.setattr(run_output.requests, 'post', fake_post) + monkeypatch.setattr(run_output, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert posts == [webhook_url, slack_url] + assert warnings == [f"push_outputs: webhook failed with status {status_code}"] + assert "response-secret" not in warnings[0] + + @pytest.mark.parametrize("status_code", [200, 204]) + def test_remote_2xx_responses_are_silent(self, monkeypatch, status_code): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['webhook', 'slack']) + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://example.test/webhook') + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', 'https://example.test/slack') + warnings = [] + monkeypatch.setattr(run_output.requests, 'post', + lambda *args, **kwargs: SimpleNamespace(status_code=status_code)) + monkeypatch.setattr(run_output, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert warnings == [] + + def test_malformed_webhook_url_does_not_skip_slack(self, monkeypatch): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['webhook', 'slack']) + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://[') + slack_url = 'https://example.test/slack' + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', slack_url) + posts = [] + warnings = [] + monkeypatch.setattr(run_output.requests, 'post', + lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) + monkeypatch.setattr(run_output, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert posts == [slack_url] + assert warnings == ["push_outputs: webhook failed: ValueError"] + assert "https://[" not in warnings[0] + + def test_stdout_failure_does_not_skip_later_destinations(self, monkeypatch, tmp_path): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['stdout', 'file', 'webhook', 'slack']) + out = tmp_path / 'out.jsonl' + get_settings().set('PUSH_OUTPUTS.FILE_PATH', str(out)) + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://example.test/webhook') + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', 'https://example.test/slack') + posts = [] + warnings = [] + + def fail_print(*args, **kwargs): + raise OSError("stdout-secret") + + monkeypatch.setattr('builtins.print', fail_print) + monkeypatch.setattr(run_output.requests, 'post', + lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) + monkeypatch.setattr(run_output, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert out.exists() + assert posts == ['https://example.test/webhook', 'https://example.test/slack'] + assert warnings == ["push_outputs: stdout failed: OSError"] + + def test_file_failure_does_not_skip_remote_destinations(self, monkeypatch, tmp_path): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['file', 'webhook', 'slack']) + get_settings().set('PUSH_OUTPUTS.FILE_PATH', str(tmp_path)) + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://example.test/webhook') + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', 'https://example.test/slack') + posts = [] + warnings = [] + monkeypatch.setattr(run_output.requests, 'post', + lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) + monkeypatch.setattr(run_output, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert posts == ['https://example.test/webhook', 'https://example.test/slack'] + assert warnings == ["push_outputs: file failed: IsADirectoryError"] + + def test_slack_failure_is_non_fatal_and_secret_safe(self, monkeypatch): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['webhook', 'slack']) + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', 'https://example.test/webhook') + slack_url = 'https://example.test/slack-secret' + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', slack_url) + warnings = [] + + def fake_post(url, **kwargs): + if url == slack_url: + raise TimeoutError("slack-timeout-secret") + return SimpleNamespace(status_code=200) + + monkeypatch.setattr(run_output.requests, 'post', fake_post) + monkeypatch.setattr(run_output, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert warnings == ["push_outputs: slack failed: TimeoutError"] + assert slack_url not in warnings[0] + assert "slack-timeout-secret" not in warnings[0] + + def test_slack_non_2xx_warns_without_response_content(self, monkeypatch): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['slack']) + get_settings().set('PUSH_OUTPUTS.SLACK_WEBHOOK_URL', 'https://example.test/slack') + warnings = [] + monkeypatch.setattr(run_output.requests, 'post', + lambda *args, **kwargs: SimpleNamespace(status_code=429, text="response-secret")) + monkeypatch.setattr(run_output, 'get_logger', + lambda: SimpleNamespace(warning=warnings.append)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert warnings == ["push_outputs: slack failed with status 429"] + assert "response-secret" not in warnings[0] + + def test_unknown_and_duplicate_channels_do_not_duplicate_delivery(self, monkeypatch): + get_settings().set('PUSH_OUTPUTS.ENABLE', True) + get_settings().set('PUSH_OUTPUTS.CHANNELS', ['unknown', 'webhook', 'webhook']) + webhook_url = 'https://example.test/webhook' + get_settings().set('PUSH_OUTPUTS.WEBHOOK_URL', webhook_url) + posts = [] + monkeypatch.setattr(run_output.requests, 'post', + lambda url, **kwargs: posts.append(url) or SimpleNamespace(status_code=200)) + + push_outputs("review", payload={"a": 1}, markdown="hi") + + assert posts == [webhook_url] diff --git a/tests/unittest/test_register_git_provider.py b/tests/unittest/test_register_git_provider.py index 647918e16a..0f54036eec 100644 --- a/tests/unittest/test_register_git_provider.py +++ b/tests/unittest/test_register_git_provider.py @@ -58,7 +58,9 @@ def get_repo_settings(self): def publish_comment(self, pr_comment: str, is_temporary: bool = False): pass - def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None): + def publish_inline_comment( + self, body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None + ): pass def publish_inline_comments(self, comments: list[dict]): diff --git a/tests/unittest/test_repo_context.py b/tests/unittest/test_repo_context.py index 67075577f1..aac196e5d1 100644 --- a/tests/unittest/test_repo_context.py +++ b/tests/unittest/test_repo_context.py @@ -1,7 +1,9 @@ +from types import SimpleNamespace from unittest.mock import Mock, patch import pytest from github import GithubException +from gitlab.exceptions import GitlabGetError from jinja2 import Environment, StrictUndefined, select_autoescape from pr_agent.algo import repo_context @@ -13,6 +15,14 @@ render_instruction_files_with_line_budget, ) from pr_agent.config_loader import get_settings +from pr_agent.git_providers import ( + AzureDevopsProvider, + BitbucketProvider, + BitbucketServerProvider, + GiteaProvider, + GitLabProvider, +) +from pr_agent.git_providers.codecommit_provider import CodeCommitProvider from pr_agent.git_providers.git_provider import GitProvider from pr_agent.git_providers.github_provider import GithubProvider @@ -29,25 +39,48 @@ def get_repo_file_content(self, file_path: str, from_default_branch: bool = Fals self.from_default_branch_calls.append(from_default_branch) return self.files.get(file_path) + def get_repo_context_ref(self, from_default_branch: bool = False): + # Simulate a provider that keys its content on a revision: the default branch uses a + # stable name, and the target/base branch uses a commit-derived value. + return "default" if from_default_branch else "target-sha" + class UnsupportedProvider: get_repo_file_content = GitProvider.get_repo_file_content +class SiblingFakeProvider(FakeProvider): + """FakeProvider that additionally resolves sibling-repository files.""" + + def __init__(self, files, sibling_files=None, pr_url=None): + super().__init__(files, pr_url) + self.sibling_files = sibling_files or {} + self.requested_siblings = [] + + def get_sibling_repo_file_content(self, repo_id: str, file_path: str, from_default_branch: bool = False): + self.requested_siblings.append(f"{repo_id}:{file_path}") + return self.sibling_files.get(f"{repo_id}:{file_path}") + + @pytest.fixture def repo_context_settings(): settings = get_settings() original_files = settings.config.get("repo_context_files", []) original_max_lines = settings.config.get("repo_context_max_lines", 500) original_from_default_branch = settings.config.get("repo_context_from_default_branch", True) + original_sibling_repos = settings.config.get("repo_context_sibling_repos", []) + settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", []) + original_max_sibling_files = settings.config.get("repo_context_max_sibling_files", 5) original_warned_provider_classes = repo_context._unsupported_repo_context_provider_classes.copy() original_process_cache = repo_context._repo_context_process_cache.copy() yield settings + settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", original_sibling_repos) settings.set("CONFIG.REPO_CONTEXT_FILES", original_files) settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", original_max_lines) settings.set("CONFIG.REPO_CONTEXT_FROM_DEFAULT_BRANCH", original_from_default_branch) + settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", original_max_sibling_files) repo_context._unsupported_repo_context_provider_classes = original_warned_provider_classes repo_context._repo_context_process_cache = original_process_cache @@ -287,7 +320,7 @@ def test_load_repo_context_files_normalizes_fetch_results(): provider, ["AGENTS.md", "EMPTY.md", "MISSING.md", " "] ) - assert files == {"AGENTS.md": "Repo purpose"} + assert files == [("AGENTS.md", "Repo purpose")] assert had_fetch_error is False assert provider.requested_paths == ["AGENTS.md", "EMPTY.md", "MISSING.md"] @@ -298,10 +331,937 @@ def test_load_repo_context_files_reports_fetch_errors(): files, had_fetch_error = repo_context._load_repo_context_files(provider, ["AGENTS.md"]) - assert files == {} + assert files == [] + assert had_fetch_error is True + + +@pytest.mark.parametrize( + "entry,expected_repo_id,expected_path", + [ + ("AGENTS.md", None, "AGENTS.md"), + ("docs/guide.md", None, "docs/guide.md"), + (" src/ x.py ", None, "src/ x.py"), + ("docs/guide:part.md", None, "docs/guide:part.md"), + ({"repo_id": "group/sub/lib", "file_path": "src/api.py"}, "group/sub/lib", "src/api.py"), + ({"repo_id": "owner/lib", "file_path": "/deep/file.py"}, "owner/lib", "deep/file.py"), + ({"repo_id": "owner/lib", "file_path": " file.py "}, "owner/lib", "file.py"), + ({"repo_id": " owner/lib ", "file_path": " file.py "}, "owner/lib", "file.py"), + ], +) +def test_parse_repo_context_file_entry(entry, expected_repo_id, expected_path): + assert repo_context._parse_repo_context_file_entry(entry) == (expected_repo_id, expected_path) + + +@pytest.mark.parametrize( + "entry", + [ + {"repo_id": "group/lib", "file_path": ""}, + {"repo_id": "", "file_path": "src/api.py"}, + {"repo_id": "group/lib"}, + {"file_path": "src/api.py"}, + {"repo_id": 123, "file_path": "src/api.py"}, + {"repo_id": "group/lib", "file_path": "/"}, + 123, + ], +) +def test_parse_repo_context_file_entry_rejects_malformed(entry): + # The parser is pure: malformed entries read as invalid, and the loader reports the skip. + assert repo_context._parse_repo_context_file_entry(entry) == (None, "") + + +def test_parse_repo_context_file_entry_treats_blank_as_plain_path(): + assert repo_context._parse_repo_context_file_entry(" ") == (None, "") + assert repo_context._parse_repo_context_file_entry("") == (None, "") + + +def test_load_repo_context_files_fetches_sibling_and_same_repo_files(): + provider = SiblingFakeProvider( + files={"AGENTS.md": "Repo purpose"}, + sibling_files={"group/lib-api:src/interfaces/api.py": "Sibling contract"}, + ) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, + ["AGENTS.md", {"repo_id": "group/lib-api", "file_path": "src/interfaces/api.py"}], + from_default_branch=True, + ) + + # The sibling file is rendered under its sibling path so the model sees where it came from. + assert files == [ + ("AGENTS.md", "Repo purpose"), + ("group/lib-api/src/interfaces/api.py", "Sibling contract"), + ] + assert had_fetch_error is False + assert provider.requested_paths == ["AGENTS.md"] + assert provider.requested_siblings == ["group/lib-api:src/interfaces/api.py"] + + +def test_load_repo_context_files_treats_colon_string_as_local_path(): + provider = SiblingFakeProvider( + files={"docs/guide:part.md": "Local content"}, + sibling_files={"docs/guide:part.md": "Sibling content"}, + ) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, ["docs/guide:part.md"], from_default_branch=True + ) + + # A ':' inside a plain string is a local path, never a sibling reference. + assert files == [("docs/guide:part.md", "Local content")] + assert had_fetch_error is False + assert provider.requested_paths == ["docs/guide:part.md"] + assert provider.requested_siblings == [] + + +def test_load_repo_context_files_reports_sibling_fetch_errors(): + provider = SiblingFakeProvider(files={}) + provider.get_sibling_repo_file_content = Mock(side_effect=Exception("temporary outage")) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, [{"repo_id": "group/lib-api", "file_path": "src/api.py"}], from_default_branch=True + ) + + assert files == [] + assert had_fetch_error is True + + +def test_load_repo_context_files_skips_siblings_for_unsupported_provider(): + provider = FakeProvider({"AGENTS.md": "Repo purpose"}) + provider.get_sibling_repo_file_content = Mock() + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, ["AGENTS.md", {"repo_id": "group/lib-api", "file_path": "src/api.py"}], from_default_branch=True + ) + + assert files == [("AGENTS.md", "Repo purpose")] + assert had_fetch_error is False + assert provider.requested_paths == ["AGENTS.md"] + provider.get_sibling_repo_file_content.assert_not_called() + + +def test_load_repo_context_files_respects_sibling_file_cap(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", 2) + provider = SiblingFakeProvider( + files={}, + sibling_files={ + "group/g1:README.md": "one", + "group/g2:README.md": "two", + "group/g3:README.md": "three", + }, + ) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, + [ + {"repo_id": "group/g1", "file_path": "README.md"}, + {"repo_id": "group/g2", "file_path": "README.md"}, + {"repo_id": "group/g3", "file_path": "README.md"}, + {"repo_id": "group/g4", "file_path": "README.md"}, + ], + from_default_branch=True, + ) + + assert files == [ + ("group/g1/README.md", "one"), + ("group/g2/README.md", "two"), + ] + assert had_fetch_error is False + assert provider.requested_siblings == [ + "group/g1:README.md", + "group/g2:README.md", + ] + + +def test_sibling_fetch_cap_counts_unique_pairs_not_duplicate_entries(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", 2) + provider = SiblingFakeProvider( + files={}, + sibling_files={ + "group/g1:README.md": "one", + "group/g2:README.md": "two", + }, + ) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, + [ + {"repo_id": "group/g1", "file_path": "README.md"}, + {"repo_id": "group/g1", "file_path": "README.md"}, + {"repo_id": "group/g2", "file_path": "README.md"}, + ], + from_default_branch=True, + ) + + # A duplicate entry must not consume the fetch cap: each unique pair is fetched once. + assert files == [ + ("group/g1/README.md", "one"), + ("group/g2/README.md", "two"), + ] + assert had_fetch_error is False + assert provider.requested_siblings == [ + "group/g1:README.md", + "group/g2:README.md", + ] + + +def test_read_max_sibling_context_files_clamps_oversized_value(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", 10**6) + assert repo_context._read_max_sibling_context_files() == repo_context._HARD_MAX_SIBLING_CONTEXT_FILES + + +def test_read_max_sibling_context_files_defaults_on_bad_value(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", "not-a-number") + assert repo_context._read_max_sibling_context_files() == 5 + + +def test_read_max_sibling_context_files_floor_at_zero(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", -100) + assert repo_context._read_max_sibling_context_files() == 0 + + +def test_load_repo_context_files_clamps_sibling_fetches_to_hard_ceiling(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", 10**6) + hard_max = repo_context._HARD_MAX_SIBLING_CONTEXT_FILES + context_files = [{"repo_id": f"group/g{i}", "file_path": "README.md"} for i in range(hard_max + 5)] + provider = SiblingFakeProvider( + files={}, + sibling_files={f"group/g{i}:README.md": str(i) for i in range(hard_max + 5)}, + ) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, context_files, from_default_branch=True + ) + + assert had_fetch_error is False + assert provider.requested_siblings == [f"group/g{i}:README.md" for i in range(hard_max)] + + +def test_sibling_fetch_cap_counts_attempts_not_just_content(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", 2) + provider = SiblingFakeProvider( + files={}, + sibling_files={ + "group/g1:README.md": "", + "group/g2:README.md": "two", + }, + ) + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, + [ + {"repo_id": "group/g1", "file_path": "README.md"}, + {"repo_id": "group/g2", "file_path": "README.md"}, + {"repo_id": "group/g3", "file_path": "README.md"}, + ], + from_default_branch=True, + ) + + assert files == [("group/g2/README.md", "two")] + assert provider.requested_siblings == [ + "group/g1:README.md", + "group/g2:README.md", + ] + + +def test_sibling_fetch_cap_counts_exceptions(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_SIBLING_FILES", 2) + provider = SiblingFakeProvider(files={}) + call_count = 0 + original = provider.get_sibling_repo_file_content + + def counting_call(repo_id, file_path): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise Exception("transient outage") + return original(repo_id, file_path) + + provider.get_sibling_repo_file_content = Mock(side_effect=counting_call) + provider.requested_siblings = [] + + files, had_fetch_error = repo_context._load_repo_context_files( + provider, + [ + {"repo_id": "group/g1", "file_path": "README.md"}, + {"repo_id": "group/g2", "file_path": "README.md"}, + {"repo_id": "group/g3", "file_path": "README.md"}, + ], + from_default_branch=True, + ) + + assert call_count == 2 assert had_fetch_error is True +def test_build_repo_context_renders_sibling_file_with_budget(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", ["group/lib-api"]) + repo_context_settings.set( + "CONFIG.REPO_CONTEXT_FILES", + [{"repo_id": "group/lib-api", "file_path": "src/interfaces/api.py"}] + ) + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", 500) + provider = SiblingFakeProvider( + files={}, + sibling_files={"group/lib-api:src/interfaces/api.py": "def call(req): ...\n"}, + ) + + context = build_repo_context(provider) + + assert context == ( + "You are being given instruction files. Follow them as project-specific guidance when reviewing code.\n" + "\n" + '\n' + "`````markdown\n" + "def call(req): ...\n" + "`````\n" + "\n\n" + "" + ) + + +def test_build_repo_context_renders_local_and_sibling_with_same_label(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", ["group/lib"]) + repo_context_settings.set( + "CONFIG.REPO_CONTEXT_FILES", + [ + "group/lib/README.md", + {"repo_id": "group/lib", "file_path": "README.md"}, + ], + ) + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", 500) + provider = SiblingFakeProvider( + files={"group/lib/README.md": "Local README\n"}, + sibling_files={"group/lib:README.md": "Sibling README\n"}, + ) + + context = build_repo_context(provider) + + # A local path that equals a sibling's rendered label must not overwrite the other: both + # configured instruction files reach the prompt, distinguished by their source content. + assert context == ( + "You are being given instruction files. Follow them as project-specific guidance when reviewing code.\n" + "\n" + '\n' + "`````markdown\n" + "Local README\n" + "`````\n" + "\n\n" + '\n' + "`````markdown\n" + "Sibling README\n" + "`````\n" + "\n\n" + "" + ) + + +@pytest.fixture +def sibling_provider_settings(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", ["myorg/lib", "group/sub/lib", "456"]) + return repo_context_settings + + +def test_github_provider_fetches_sibling_file_in_same_owner(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + provider.github_client.get_repo.assert_called_once_with("myorg/lib") + sibling_repo.get_contents.assert_called_once_with("src/api.py") + + +def test_github_provider_fetches_private_sibling_when_requester_is_collaborator(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + sibling_repo.has_in_collaborators.return_value = True + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + sibling_repo.has_in_collaborators.assert_called_once_with("alice") + + +def test_github_provider_fetches_private_sibling_when_requester_is_owner(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + sibling_repo.owner = SimpleNamespace(login="alice") + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + sibling_repo.has_in_collaborators.assert_not_called() + + +def test_github_provider_rejects_private_sibling_when_requester_is_not_collaborator(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + sibling_repo.has_in_collaborators.return_value = False + provider.github_client.get_repo.return_value = sibling_repo + + with patch("pr_agent.git_providers.github_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + + sibling_repo.get_contents.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: myorg/lib" + ) + + +def test_github_provider_rejects_private_sibling_when_requester_is_unknown(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + sibling_repo.get_contents.assert_not_called() + + +def test_github_provider_rejects_private_sibling_when_command_actor_lacks_access(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + provider.set_command_actor("mallory") + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + sibling_repo.visibility = "private" + sibling_repo.has_in_collaborators.return_value = False + provider.github_client.get_repo.return_value = sibling_repo + + with patch("pr_agent.git_providers.github_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + + # The commenter (command actor), not the PR author, is the identity that must be verified. + sibling_repo.has_in_collaborators.assert_called_once_with("mallory") + sibling_repo.get_contents.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: myorg/lib" + ) + + +def test_github_provider_fetches_private_sibling_when_command_actor_is_collaborator(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + provider.set_command_actor("mallory") + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + sibling_repo.visibility = "private" + sibling_repo.has_in_collaborators.return_value = True + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + sibling_repo.has_in_collaborators.assert_called_once_with("mallory") + + +def test_github_provider_rejects_internal_sibling_when_requester_is_not_collaborator(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.visibility = "internal" + sibling_repo.organization = None # not an organization-controlled repository + sibling_repo.has_in_collaborators.return_value = False + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + sibling_repo.has_in_collaborators.assert_called_once_with("alice") + sibling_repo.get_contents.assert_not_called() + + +def test_github_provider_fetches_internal_sibling_when_command_actor_is_collaborator(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.set_command_actor("alice") + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.visibility = "internal" + sibling_repo.organization = None + sibling_repo.has_in_collaborators.return_value = True + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + sibling_repo.has_in_collaborators.assert_called_once_with("alice") + + +def test_github_provider_fetches_internal_sibling_when_requester_is_org_member(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.set_command_actor("alice") + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.visibility = "internal" + sibling_repo.organization = Mock() + sibling_repo.organization.has_in_members.return_value = True + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + # Internal repositories are readable by every member of the owning organization without a + # per-repo collaborator grant, so the membership check is both sufficient and necessary. + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + provider.github_client.get_user.assert_called_once_with("alice") + sibling_repo.organization.has_in_members.assert_called_once_with(provider.github_client.get_user.return_value) + sibling_repo.has_in_collaborators.assert_not_called() + + +def test_github_provider_rejects_internal_sibling_to_non_member_non_collaborator(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.set_command_actor("alice") + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.visibility = "internal" + sibling_repo.organization = Mock() + sibling_repo.organization.has_in_members.return_value = False + sibling_repo.has_in_collaborators.return_value = False + provider.github_client.get_repo.return_value = sibling_repo + + with patch("pr_agent.git_providers.github_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + + # Not an org member; outside collaborators are the remaining way into an internal repo. + provider.github_client.get_user.assert_called_once_with("alice") + sibling_repo.organization.has_in_members.assert_called_once_with(provider.github_client.get_user.return_value) + sibling_repo.has_in_collaborators.assert_called_once_with("alice") + sibling_repo.get_contents.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: myorg/lib" + ) + + +def test_github_provider_fetches_sibling_when_owner_case_differs(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "MyOrg/current" + provider.get_owning_namespace = Mock(return_value="MyOrg") + provider.github_client = Mock() + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.get_contents.return_value.decoded_content = b"sibling contract" + provider.github_client.get_repo.return_value = sibling_repo + + # Owner logins are case-insensitive on GitHub, so differently-cased siblings are valid. + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "sibling contract" + provider.github_client.get_repo.assert_called_once_with("myorg/lib") + sibling_repo.get_contents.assert_called_once_with("src/api.py") + + +def test_github_provider_propagates_sibling_collaborator_errors(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.pr = SimpleNamespace(user=SimpleNamespace(login="alice")) + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = True + sibling_repo.has_in_collaborators.side_effect = GithubException(500, {"message": "boom"}, {}) + provider.github_client.get_repo.return_value = sibling_repo + + # A transient provider failure must surface as a fetch error, not read as a denial. + with pytest.raises(GithubException): + provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") + sibling_repo.get_contents.assert_not_called() + + # A plain denial stays returnable: a definitive 404 means the requester is not a collaborator. + sibling_repo.has_in_collaborators.side_effect = GithubException(404, {"message": "Not Found"}, {}) + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + + +def test_github_provider_propagates_org_membership_errors(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.set_command_actor("alice") + sibling_repo = Mock() + sibling_repo.full_name = "myorg/lib" + sibling_repo.private = False + sibling_repo.visibility = "internal" + sibling_repo.organization = Mock() + sibling_repo.organization.has_in_members.side_effect = GithubException(500, {"message": "boom"}, {}) + provider.github_client.get_repo.return_value = sibling_repo + + with pytest.raises(GithubException): + provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") + sibling_repo.has_in_collaborators.assert_not_called() + sibling_repo.get_contents.assert_not_called() + + # A 404 (organization cannot be resolved) is not a membership verdict: fall through. + sibling_repo.organization.has_in_members.side_effect = GithubException(404, {"message": "Not Found"}, {}) + sibling_repo.has_in_collaborators.return_value = False + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + provider.github_client.get_user.assert_called_with("alice") + sibling_repo.organization.has_in_members.assert_called_with(provider.github_client.get_user.return_value) + sibling_repo.has_in_collaborators.assert_called_once_with("alice") + + +def test_github_provider_rejects_out_of_owner_sibling_without_api_call(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + + with patch("pr_agent.git_providers.github_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("other-org/lib", "src/api.py") == "" + + provider.github_client.get_repo.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo absent from the host allowlist: other-org/lib" + ) + + +def test_github_provider_treats_missing_sibling_file_as_no_context(sibling_provider_settings): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + provider.get_owning_namespace = Mock(return_value="myorg") + provider.github_client = Mock() + provider.github_client.get_repo.side_effect = GithubException(404, {"message": "Not Found"}, {}) + + assert provider.get_sibling_repo_file_content("myorg/lib", "src/api.py") == "" + + +def test_gitlab_provider_fetches_sibling_file_in_same_namespace(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "public" + sibling_project.files.get.return_value.decode.return_value = b"sibling contract" + provider.gl.projects.get.return_value = sibling_project + + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "sibling contract" + provider.gl.projects.get.assert_called_once_with("group/sub/lib") + sibling_project.files.get.assert_called_once_with(file_path="src/api.py", ref="main") + + +def test_gitlab_provider_rejects_internal_sibling_when_requester_is_not_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "internal" + sibling_project.members_all.get.side_effect = GitlabGetError("Not found", response_code=404) + provider.gl.projects.get.return_value = sibling_project + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + + # Internal projects forbid external users and low-role/unknown members, so membership + # must be positively established before the sibling is read. + sibling_project.members_all.get.assert_called_once_with(42) + sibling_project.files.get.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: group/sub/lib" + ) + + +def test_gitlab_provider_fetches_internal_sibling_when_requester_is_read_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "internal" + sibling_project.members_all.get.return_value = SimpleNamespace(access_level=30) + sibling_project.files.get.return_value.decode.return_value = b"sibling contract" + provider.gl.projects.get.return_value = sibling_project + + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "sibling contract" + sibling_project.members_all.get.assert_called_once_with(42) + + +def test_gitlab_provider_fetches_internal_sibling_for_non_external_instance_user(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + # The requester is a signed-in instance user who is not an external user. + provider.gl.users.get.return_value = SimpleNamespace(external=False) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "internal" + sibling_project.files.get.return_value.decode.return_value = b"sibling contract" + provider.gl.projects.get.return_value = sibling_project + + # Internal projects are visible to every non-external instance user without membership. + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "sibling contract" + provider.gl.users.get.assert_called_once_with(42) + sibling_project.members_all.get.assert_not_called() + sibling_project.files.get.assert_called_once_with(file_path="src/api.py", ref="main") + + +def test_gitlab_provider_rejects_internal_sibling_for_external_user_non_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + provider.gl.users.get.return_value = SimpleNamespace(external=True) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "internal" + sibling_project.members_all.get.side_effect = GitlabGetError("Not found", response_code=404) + provider.gl.projects.get.return_value = sibling_project + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + + # External users cannot be granted internal access as members either, so this fails closed. + provider.gl.users.get.assert_called_once_with(42) + sibling_project.members_all.get.assert_called_once_with(42) + sibling_project.files.get.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: group/sub/lib" + ) + + +def test_gitlab_provider_propagates_membership_provider_errors(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + sibling_project.members_all.get.side_effect = GitlabGetError("boom", response_code=500) + provider.gl.projects.get.return_value = sibling_project + + # A transient membership provider failure must surface as a fetch error, not a denial. + with pytest.raises(GitlabGetError): + provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") + sibling_project.files.get.assert_not_called() + + +def test_gitlab_provider_propagates_user_lookup_provider_errors(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + provider.gl.users.get.side_effect = GitlabGetError("boom", response_code=500) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "internal" + provider.gl.projects.get.return_value = sibling_project + + with pytest.raises(GitlabGetError): + provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") + sibling_project.members_all.get.assert_not_called() + sibling_project.files.get.assert_not_called() + + # A definitive 404 (no such user) is a plain denial: the requester cannot be a member. + provider.gl.users.get.side_effect = GitlabGetError("Not found", response_code=404) + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + sibling_project.members_all.get.assert_not_called() + + +def test_gitlab_provider_rejects_private_sibling_when_member_lacks_repo_read(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + sibling_project.members_all.get.return_value = SimpleNamespace(access_level=10) + sibling_project.files.get.return_value.decode.return_value = b"sibling contract" + provider.gl.projects.get.return_value = sibling_project + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + + # Guests and minimal-access members cannot read private repository source files. + sibling_project.members_all.get.assert_called_once_with(42) + sibling_project.files.get.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: group/sub/lib" + ) + + +def test_gitlab_provider_fetches_private_sibling_when_requester_is_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + sibling_project.members_all.get.return_value = SimpleNamespace(access_level=30) + sibling_project.files.get.return_value.decode.return_value = b"sibling contract" + provider.gl.projects.get.return_value = sibling_project + + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "sibling contract" + sibling_project.members_all.get.assert_called_once_with(42) + + +def test_gitlab_provider_rejects_private_sibling_when_command_actor_is_not_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + provider.set_command_actor(99) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + sibling_project.members_all.get.side_effect = GitlabGetError("Not found", response_code=404) + provider.gl.projects.get.return_value = sibling_project + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + + # The commenter (command actor), not the MR author, is the identity that must be verified. + sibling_project.members_all.get.assert_called_once_with(99) + sibling_project.files.get.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: group/sub/lib" + ) + + +def test_gitlab_provider_fetches_private_sibling_when_command_actor_is_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + provider.set_command_actor(99) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + sibling_project.members_all.get.return_value = SimpleNamespace(access_level=30) + sibling_project.files.get.return_value.decode.return_value = b"sibling contract" + provider.gl.projects.get.return_value = sibling_project + + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "sibling contract" + sibling_project.members_all.get.assert_called_once_with(99) + + +def test_gitlab_provider_rejects_private_sibling_when_requester_is_not_member(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + provider.mr = SimpleNamespace(author={"id": 42}) + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + sibling_project.members_all.get.side_effect = GitlabGetError("Not found", response_code=404) + provider.gl.projects.get.return_value = sibling_project + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + + sibling_project.files.get.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo context file the review requester cannot read: group/sub/lib" + ) + + +def test_gitlab_provider_rejects_private_sibling_when_requester_is_unknown(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + sibling_project = Mock() + sibling_project.path_with_namespace = "group/sub/lib" + sibling_project.default_branch = "main" + sibling_project.visibility = "private" + provider.gl.projects.get.return_value = sibling_project + + assert provider.get_sibling_repo_file_content("group/sub/lib", "src/api.py") == "" + sibling_project.files.get.assert_not_called() + sibling_project.members_all.get.assert_not_called() + + +def test_gitlab_provider_rejects_out_of_namespace_sibling_without_api_call(sibling_provider_settings): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/sub/current" + provider.get_owning_namespace = Mock(return_value="group") + provider.gl = Mock() + + with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_get_logger: + assert provider.get_sibling_repo_file_content("other/lib", "src/api.py") == "" + + provider.gl.projects.get.assert_not_called() + mock_get_logger.return_value.warning.assert_called_once_with( + "Ignoring sibling repo absent from the host allowlist: other/lib" + ) + + def test_build_repo_context_process_cache_invalidates_when_config_changes(repo_context_settings): repo_context_settings.set("CONFIG.REPO_CONTEXT_FILES", ["AGENTS.md"]) repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", 500) @@ -335,7 +1295,130 @@ def test_build_repo_context_does_not_cache_empty_context_after_fetch_error(repo_ assert first_context == "" assert "Repo purpose" in second_context - assert provider.get_repo_file_content.call_count == 2 + + +def test_build_repo_context_does_not_cache_sibling_content(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", ["group/lib"]) + repo_context_settings.set( + "CONFIG.REPO_CONTEXT_FILES", + [{"repo_id": "group/lib", "file_path": "api.py"}] + ) + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", 500) + provider = SiblingFakeProvider( + files={}, + sibling_files={"group/lib:api.py": "def call(req): ...\n"}, + pr_url="https://example.com/org/repo/pull/1", + ) + + first_context = build_repo_context(provider) + assert "' in rendered + + +class RefishProvider(FakeProvider): + """A provider whose repo-context revision can move between calls, like a rebased base branch.""" + + def __init__(self, files, pr_url=None): + super().__init__(files, pr_url) + self.context_ref = "sha-1" + + def get_repo_context_ref(self, from_default_branch: bool = False): + return self.context_ref + + +def test_build_repo_context_process_cache_refreshes_when_revision_changes(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_FILES", ["AGENTS.md"]) + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", 500) + pr_url = "https://example.com/org/repo/pull/1" + provider = RefishProvider({"AGENTS.md": "before rebase"}, pr_url=pr_url) + + first_context = build_repo_context(provider) + assert "before rebase" in first_context + + # The base branch moved (rebase/push within the TTL): the revision the cache is keyed on + # changes, so the stale entry must not be served. + provider.context_ref = "sha-2" + provider.files["AGENTS.md"] = "after rebase" + + second_context = build_repo_context(provider) + + assert "after rebase" in second_context + assert "before rebase" not in second_context + assert provider.requested_paths == ["AGENTS.md", "AGENTS.md"] + + +def test_build_repo_context_provider_cache_refreshes_when_revision_changes(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_FILES", ["AGENTS.md"]) + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", 500) + provider = RefishProvider({"AGENTS.md": "before rebase"}) + + first_context = build_repo_context(provider) + assert "before rebase" in first_context + + provider.context_ref = "sha-3" + provider.files["AGENTS.md"] = "after push" + + second_context = build_repo_context(provider) + + assert "after push" in second_context + assert provider.requested_paths == ["AGENTS.md", "AGENTS.md"] + + +def test_get_repo_context_ref_github_returns_base_sha(): + provider = GithubProvider.__new__(GithubProvider) + provider.base_url = "https://api.github.com" + provider.provider_id = None + provider.pr = Mock(base=Mock(sha="base-sha", ref="release/1.0")) + + assert provider.get_repo_context_ref() == "base-sha" + + +def test_get_repo_context_ref_github_resolves_default_branch_head(): + """Reading the default branch keys the cache on its head commit, so a push to it + invalidates cached content within the TTL instead of serving a moved commit.""" + provider = GithubProvider.__new__(GithubProvider) + provider.repo_obj = Mock() + provider.repo_obj.default_branch = "main" + provider.repo_obj.get_branch.return_value.commit.sha = "default-sha" + + assert provider.get_repo_context_ref(from_default_branch=True) == "default-sha" + provider.repo_obj.get_branch.assert_called_once_with("main") + + +def test_get_repo_context_ref_github_without_pr_base_reads_default_branch_head(): + """Without a PR base the fallback read also comes from the default branch, so the + same key rule applies.""" + provider = GithubProvider.__new__(GithubProvider) + provider.pr = None + provider.repo_obj = Mock() + provider.repo_obj.default_branch = "main" + provider.repo_obj.get_branch.return_value.commit.sha = "default-sha" + + assert provider.get_repo_context_ref() == "default-sha" + provider.repo_obj.get_branch.assert_called_once_with("main") + + +def test_get_repo_context_ref_github_falls_back_to_none_without_repo_obj(): + provider = GithubProvider.__new__(GithubProvider) + + assert provider.get_repo_context_ref(from_default_branch=True) is None + assert provider.get_repo_context_ref() is None + + +def test_build_repo_context_github_invalidates_default_branch_cache_when_head_moves(repo_context_settings): + """The shipping default reads repo-context from the default branch; a push to it + within the TTL must not serve the previous head's content. Keying the cache on the + resolved head commit closes the gap the review called out on GitHub.""" + repo_context_settings.set("CONFIG.REPO_CONTEXT_FILES", ["AGENTS.md"]) + repo_context_settings.set("CONFIG.REPO_CONTEXT_MAX_LINES", 500) + repo_context_settings.set("CONFIG.REPO_CONTEXT_FROM_DEFAULT_BRANCH", True) + provider = GithubProvider.__new__(GithubProvider) + provider.pr = Mock(base=Mock(sha="base-sha", ref="release/1.0")) + provider.repo_obj = Mock() + provider.repo_obj.default_branch = "main" + provider.repo_obj.get_contents.return_value.decoded_content = b"before push" + provider.repo_obj.get_branch.return_value.commit.sha = "head-1" + + first_context = build_repo_context(provider) + assert "before push" in first_context + + provider.repo_obj.get_contents.return_value.decoded_content = b"after push" + provider.repo_obj.get_branch.return_value.commit.sha = "head-2" + + second_context = build_repo_context(provider) + + assert "after push" in second_context + assert "before push" not in second_context + assert provider.repo_obj.get_contents.call_count == 2 + + +def test_get_repo_context_ref_gitlab_returns_target_branch(): + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "owner/repo" + provider.mr = Mock(target_branch="main") + provider.gl = Mock() + provider.gl.projects.get.return_value.default_branch = "project-default" + + assert provider.get_repo_context_ref() == "main" + assert provider.get_repo_context_ref(from_default_branch=True) == "project-default" + + +def test_get_repo_context_ref_azure_returns_base_commit(): + provider = AzureDevopsProvider.__new__(AzureDevopsProvider) + provider.workspace_slug = "my-project" + provider.repo_slug = "my-repo" + provider.pr = Mock(last_merge_target_commit=Mock(commit_id="base-sha")) + + assert provider.get_repo_context_ref() == "base-sha" + assert provider.get_repo_context_ref(from_default_branch=True) is None + + +def test_get_repo_context_ref_bitbucket_server_returns_base_commit(): + provider = BitbucketServerProvider.__new__(BitbucketServerProvider) + provider.workspace_slug = "PRJ" + provider.repo_slug = "repo" + provider.pr = SimpleNamespace(toRef={"latestCommit": "base-sha"}) + provider.bitbucket_client = Mock() + + assert provider.get_repo_context_ref() == "base-sha" + provider.bitbucket_client.get_default_branch.return_value = {"displayId": "develop"} + assert provider.get_repo_context_ref(from_default_branch=True) == "develop" + + +def test_get_repo_context_ref_gitea_returns_base_ref(): + provider = GiteaProvider.__new__(GiteaProvider) + provider.logger = Mock() + provider.owner = "owner" + provider.repo = "repo" + provider.base_sha = "base-sha" + provider.base_ref = None + provider.repo_api = Mock() + + assert provider.get_repo_context_ref() == "base-sha" + provider.repo_api.repo_get.return_value.default_branch = "main" + assert provider.get_repo_context_ref(from_default_branch=True) == "main" + + +def test_get_repo_context_ref_bitbucket_cloud_returns_destination_branch(): + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.workspace_slug = "myws" + provider.repo_slug = "myrepo" + provider.pr = Mock(destination_branch="main") + provider.token = None + + assert provider.get_repo_context_ref() == "main" + provider.get_repo_default_branch = Mock(return_value="develop") + assert provider.get_repo_context_ref(from_default_branch=True) == "develop" + + +def test_get_repo_context_ref_inherits_none_for_providers_without_repo_context(): + provider = CodeCommitProvider.__new__(CodeCommitProvider) + + assert provider.get_repo_context_ref() is None + assert provider.get_repo_context_ref(from_default_branch=True) is None + + +@pytest.mark.parametrize("provider_class", [GithubProvider, GitLabProvider]) +@pytest.mark.parametrize("allowlist", [[], "group/lib", None]) +def test_sibling_reads_require_host_approval(repo_context_settings, provider_class, allowlist): + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", allowlist) + provider = provider_class.__new__(provider_class) + provider.github_client = Mock() + provider.gl = Mock() + + assert provider.get_sibling_repo_file_content("group/lib", "api.py") == "" + provider.github_client.get_repo.assert_not_called() + provider.gl.projects.get.assert_not_called() + + +@pytest.mark.parametrize("current_id", ["123", "group/app/current"]) +@pytest.mark.parametrize("sibling_id", ["456", "group/libraries/lib"]) +def test_gitlab_siblings_resolve_numeric_ids_and_subgroups(repo_context_settings, current_id, sibling_id): + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", [sibling_id]) + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = current_id + current = SimpleNamespace(path_with_namespace="group/app/current") + sibling = Mock(id=456, path_with_namespace="group/libraries/lib", visibility="public", default_branch="main") + sibling.files.get.return_value.decode.return_value = b"interface" + provider.gl = Mock() + provider.gl.projects.get.side_effect = lambda identifier: current if identifier == current_id else sibling + + assert provider.get_sibling_repo_file_content(sibling_id, "api.py") == "interface" + sibling.files.get.assert_called_once_with(file_path="api.py", ref="main") + + +@pytest.mark.parametrize("sibling_id,resolved_path", [ + ("group/lib", "other/lib"), + ("group/lib", "group/renamed"), + ("456", "other/lib"), +]) +def test_gitlab_rejects_resolved_identity_or_namespace_change(repo_context_settings, sibling_id, resolved_path): + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", [sibling_id]) + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/current" + current = SimpleNamespace(path_with_namespace="group/current") + sibling = Mock(id=456, path_with_namespace=resolved_path, visibility="public") + provider.gl = Mock() + provider.gl.projects.get.side_effect = lambda identifier: current if identifier == provider.id_project else sibling + + assert provider.get_sibling_repo_file_content(sibling_id, "api.py") == "" + sibling.files.get.assert_not_called() + + +@pytest.mark.parametrize("current_path,sibling_path,expected", [ + ("MyOrg/current", "myorg/lib", "interface"), + ("other/current", "myorg/lib", ""), + ("myorg/current", "other/lib", ""), + ("myorg/current", "myorg/renamed", ""), +]) +def test_github_checks_resolved_owners_and_identity(repo_context_settings, current_path, sibling_path, expected): + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", ["MyOrg/lib"]) + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "myorg/current" + current = SimpleNamespace(full_name=current_path) + sibling = Mock(full_name=sibling_path, private=False, visibility="public") + sibling.get_contents.return_value.decoded_content = b"interface" + provider.github_client = Mock() + provider.github_client.get_repo.side_effect = lambda identifier: current if identifier == provider.repo else sibling + + assert provider.get_sibling_repo_file_content("myorg/lib", "api.py") == expected + if not expected: + sibling.get_contents.assert_not_called() + + +def test_selected_sibling_build_requires_allowlist(repo_context_settings): + repo_context_settings.set("CONFIG.REPO_CONTEXT_FILES", [ + {"repo_id": "group/lib", "file_path": "api.py"}, + ]) + provider = GitLabProvider.__new__(GitLabProvider) + provider.id_project = "group/current" + provider.gl = Mock() + sibling = Mock(id=456, path_with_namespace="group/lib", visibility="public", default_branch="main") + sibling.files.get.return_value.decode.return_value = b"interface" + provider.gl.projects.get.side_effect = lambda identifier: ( + SimpleNamespace(path_with_namespace="group/current") if identifier == provider.id_project else sibling + ) + + assert build_repo_context(provider) == "" + provider.gl.projects.get.assert_not_called() + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", ["group/lib"]) + assert "interface" in build_repo_context(provider) + repo_context_settings.set("CONFIG.REPO_CONTEXT_SIBLING_REPOS", []) + assert build_repo_context(provider) == "" diff --git a/tests/unittest/test_repo_context_fences.py b/tests/unittest/test_repo_context_fences.py new file mode 100644 index 0000000000..e6e640bfc9 --- /dev/null +++ b/tests/unittest/test_repo_context_fences.py @@ -0,0 +1,51 @@ +import pytest + +from pr_agent.algo.repo_context import ( + INSTRUCTION_FILES_INTRO, + TRUNCATION_MARKER, + _get_markdown_fence, + render_instruction_files, + render_instruction_files_with_line_budget, +) + + +@pytest.mark.parametrize( + ("content", "fence_length"), + [ + ("", 5), + ("Use `type hints`.\n```python\nx = 1\n```", 5), + ("````\n````", 5), + ("`````", 6), + ("``````\n`````", 7), + ("中文```````說明`````", 8), + (" ".join(["`"] * 2000) + "\n`````", 6), + ("prefix\n" + "`" * 50000 + "\nsuffix", 50001), + ], +) +def test_markdown_fence_exceeds_longest_backtick_run(content, fence_length): + assert _get_markdown_fence(content) == "`" * fence_length + + +def test_markdown_fence_scan_follows_configured_minimum(monkeypatch): + monkeypatch.setattr("pr_agent.algo.repo_context.MARKDOWN_FENCE", "````") + assert _get_markdown_fence("````") == "`````" + + +@pytest.mark.parametrize("max_lines", [None, 10]) +def test_instruction_renderer_preserves_fences_when_truncating(max_lines): + content = "Before\n`````````\nAfter\n`````" + files = {"AGENTS.md": content} + if max_lines is None: + rendered = render_instruction_files(files) + expected_content = content + else: + rendered = render_instruction_files_with_line_budget(files, max_lines) + expected_content = f"Before\n{TRUNCATION_MARKER}" + assert len(rendered.splitlines()) == max_lines + + assert rendered == ( + f"{INSTRUCTION_FILES_INTRO}\n\n" + '\n' + f"``````````markdown\n{expected_content}\n``````````\n" + "\n\n" + ) diff --git a/tests/unittest/test_review_comment_identity.py b/tests/unittest/test_review_comment_identity.py index edb9561280..814b35595c 100644 --- a/tests/unittest/test_review_comment_identity.py +++ b/tests/unittest/test_review_comment_identity.py @@ -3,16 +3,17 @@ import pytest -from pr_agent.algo.utils import ( +from pr_agent.algo.comment_identity import ( PRReviewIdentity, add_pr_review_identity, comment_matches_identity, - convert_to_markdown_v2, format_pr_review_header, get_pr_review_comment_identifiers, ) +from pr_agent.algo.utils import convert_to_markdown_v2 from pr_agent.config_loader import get_settings from pr_agent.git_providers.azuredevops_provider import AzureDevopsProvider +from pr_agent.git_providers.bitbucket_provider import BitbucketProvider from pr_agent.git_providers.gitea_provider import GiteaProvider from pr_agent.git_providers.github_provider import GithubProvider from tests.unittest._settings_helpers import restore_settings, snapshot_settings @@ -174,11 +175,35 @@ def test_github_comment_path_forwards_review_identity(): restore_settings(snapshot) assert provider.publish_persistent_comment_full.call_args.kwargs == { + "as_thread": False, "identity_marker": PRReviewIdentity.REGULAR.value, "legacy_initial_header": legacy_header, } +def test_github_comment_path_forwards_as_thread(): + snapshot = snapshot_settings(["github.publish_as_check_run"]) + provider = GithubProvider.__new__(GithubProvider) + provider.publish_persistent_comment_full = MagicMock() + review = "## Team Review 🔍\n\nbody" + try: + get_settings().set("github.publish_as_check_run", False) + + provider.publish_persistent_comment( + review, + initial_header="## Team Review 🔍", + as_thread=True, + ) + finally: + restore_settings(snapshot) + + assert provider.publish_persistent_comment_full.call_args.kwargs == { + "as_thread": True, + "identity_marker": None, + "legacy_initial_header": None, + } + + def test_azure_comment_path_forwards_review_identity(): provider = AzureDevopsProvider.__new__(AzureDevopsProvider) provider.publish_persistent_comment_full = MagicMock() @@ -193,11 +218,33 @@ def test_azure_comment_path_forwards_review_identity(): ) assert provider.publish_persistent_comment_full.call_args.kwargs == { + "as_thread": False, "identity_marker": PRReviewIdentity.REGULAR.value, "legacy_initial_header": legacy_header, } +def test_bitbucket_comment_path_forwards_review_identity(): + provider = BitbucketProvider.__new__(BitbucketProvider) + provider.pr = MagicMock() + provider.pr.comments.return_value = [] + provider.publish_comment = MagicMock() + review = "## Team Review 🔍\n\nbody" + legacy_header = "## PR Reviewer Guide 🔍" + + provider.publish_persistent_comment( + review, + initial_header="## Team Review 🔍", + identity_marker=PRReviewIdentity.REGULAR.value, + legacy_initial_header=legacy_header, + ) + + assert provider.supports_review_comment_identity() is True + provider.publish_comment.assert_called_once_with( + "## Team Review 🔍\n\n[pr-agent:review:full]: https://github.com/The-PR-Agent/pr-agent\n\nbody" + ) + + def test_gitea_keeps_identity_inactive_but_preserves_wrapper_arguments(): provider = GiteaProvider.__new__(GiteaProvider) published = object() @@ -215,6 +262,7 @@ def test_gitea_keeps_identity_inactive_but_preserves_wrapper_arguments(): assert provider.supports_review_comment_identity() is False assert result is published assert provider.publish_persistent_comment_full.call_args.kwargs == { + "as_thread": False, "identity_marker": PRReviewIdentity.REGULAR.value, "legacy_initial_header": legacy_header, } diff --git a/tests/unittest/test_review_finding_persistence.py b/tests/unittest/test_review_finding_persistence.py index 25c3acac4f..10e841527a 100644 --- a/tests/unittest/test_review_finding_persistence.py +++ b/tests/unittest/test_review_finding_persistence.py @@ -24,6 +24,37 @@ def _reviewer(provider): return reviewer +class _PersistentCommentProvider: + publish_persistent_comment = GitProvider.publish_persistent_comment + publish_persistent_comment_full = GitProvider.publish_persistent_comment_full + supports_comment_editing = GitProvider.supports_comment_editing + + def __init__(self, comments): + self.comments = comments + self.edited = [] + self.published = [] + + def get_issue_comments(self): + return self.comments + + def get_issue_comments_newest_first(self): + return list(reversed(self.comments)) + + def get_latest_commit_url(self): + return "commit-url" + + def get_comment_url(self, comment): + return "comment-url" + + def edit_comment(self, comment, body): + self.edited.append((comment, body)) + return True + + def publish_comment(self, body, **kwargs): + self.published.append((body, kwargs)) + return "published" + + def test_invalid_structured_finding_fails_closed(monkeypatch): settings = get_settings() monkeypatch.setattr(settings.config, "publish_output", True) @@ -150,7 +181,7 @@ def test_stateful_persistent_update_still_creates_first_comment(): provider.publish_comment.assert_called_once_with("new review") -def test_stateful_mode_is_disabled_for_generic_persistent_publisher(monkeypatch): +def test_stateful_mode_is_enabled_for_generic_persistent_publisher(monkeypatch): settings = get_settings() monkeypatch.setattr(settings.config, "publish_output", True) monkeypatch.setattr(settings.pr_reviewer, "persistent_comment", True) @@ -159,7 +190,36 @@ def test_stateful_mode_is_disabled_for_generic_persistent_publisher(monkeypatch) provider.publish_persistent_comment = GitProvider.publish_persistent_comment.__get__(provider, type(provider)) provider.is_supported.return_value = True reviewer = _reviewer(provider) - assert reviewer._review_finding_state_enabled() is False + assert reviewer._review_finding_state_enabled() is True + + +def test_base_persistent_comment_updates_matching_comment(): + header = "## PR Reviewer Guide 🔍" + comment = SimpleNamespace(body=f"{header}\n\nold review") + provider = _PersistentCommentProvider([comment]) + + result = provider.publish_persistent_comment( + f"{header}\n\nnew review", + initial_header=header, + update_header=False, + final_update_message=False, + ) + + assert result is comment + assert provider.edited == [(comment, f"{header}\n\nnew review")] + assert provider.published == [] + + +def test_base_persistent_comment_creates_comment_when_no_match_exists(): + provider = _PersistentCommentProvider([]) + + result = provider.publish_persistent_comment( + "new review", + initial_header="## PR Reviewer Guide 🔍", + ) + + assert result == "published" + assert provider.published == [("new review", {})] def test_malformed_state_marker_is_replaced_without_duplicate_comment(): @@ -249,3 +309,40 @@ def test_dict_comment_edit_failure_does_not_fallback(): assert result is None provider.edit_comment.assert_called_once_with(comment, "new review") provider.publish_comment.assert_not_called() + + +class _NoEditProvider(_PersistentCommentProvider): + """A provider that never implemented edit_comment, like gerrit and codecommit. + + The inherited base edit_comment is a no-op returning None, which + publish_persistent_comment_full cannot tell apart from a successful edit. + """ + + edit_comment = GitProvider.edit_comment + supports_comment_editing = GitProvider.supports_comment_editing + + +def test_provider_without_edit_comment_publishes_the_body_instead_of_losing_it(): + header = "## PR Reviewer Guide 🔍" + comment = SimpleNamespace(body=f"{header}\n\nold review") + provider = _NoEditProvider([comment]) + + assert provider.supports_comment_editing() is False + + result = provider.publish_persistent_comment( + f"{header}\n\nnew review", + initial_header=header, + update_header=False, + final_update_message=False, + ) + + # The review body itself must reach the PR. Delegating to the full + # implementation here would no-op the edit and publish only the + # "updated to latest commit" status line, silently dropping the review. + assert result == "published" + assert provider.published == [(f"{header}\n\nnew review", {})] + + +def test_provider_with_edit_comment_reports_editing_support(): + provider = _PersistentCommentProvider([]) + assert GitProvider.supports_comment_editing(provider) is True diff --git a/tests/unittest/test_review_large_diff_chunking.py b/tests/unittest/test_review_large_diff_chunking.py index 87b36d020a..87ad227d06 100644 --- a/tests/unittest/test_review_large_diff_chunking.py +++ b/tests/unittest/test_review_large_diff_chunking.py @@ -10,6 +10,8 @@ import pytest +from pr_agent.algo.pr_processing import PreparedPRDiff +from pr_agent.algo.review_finding_state import ParsedReviewState, reconcile_review_findings from pr_agent.config_loader import get_settings from pr_agent.tools.pr_reviewer import PRReviewer from tests.unittest._settings_helpers import restore_settings, snapshot_settings @@ -17,7 +19,7 @@ _TRACKED_KEYS = ("pr_reviewer.enable_large_pr_chunking", "pr_reviewer.max_number_of_calls") CHUNK_A = """review: - score: "90" + score: 90 key_issues_to_review: - relevant_file: | a.py @@ -32,7 +34,7 @@ """ CHUNK_B = """review: - score: "40" + score: 40 key_issues_to_review: [] security_concerns: | SQL injection: the query is built by string concatenation @@ -42,7 +44,10 @@ def _make_reviewer(): reviewer = PRReviewer.__new__(PRReviewer) reviewer.git_provider = MagicMock() + reviewer.ai_handler = MagicMock() reviewer.token_handler = MagicMock() + reviewer.token_handler.prompt_tokens = 0 + reviewer.token_handler.count_tokens.side_effect = len reviewer.pr_url = "https://example/pr/1" reviewer.incremental = SimpleNamespace(is_incremental=False) reviewer.remaining_files_list = [] @@ -55,7 +60,8 @@ def chunking_enabled(): snapshot = snapshot_settings(_TRACKED_KEYS) get_settings().set("pr_reviewer.enable_large_pr_chunking", True) get_settings().set("pr_reviewer.max_number_of_calls", 3) - yield + with patch("pr_agent.algo.token_budget.get_max_tokens", return_value=10000): + yield restore_settings(snapshot) @@ -110,11 +116,12 @@ async def test_a_truncated_diff_is_reviewed_chunk_by_chunk_and_merged(chunking_e max_calls=3, add_line_numbers=True, return_remaining_files=True, + output_token_reserve=reviewer.ai_handler.get_output_token_reserve, ) assert [call.args[1] for call in reviewer._get_prediction.await_args_list] == ["chunk-a", "chunk-b"] review = reviewer.prediction_data["review"] - assert review["score"] == "40" # the worst chunk sets the score + assert review["score"] == 40 # the worst chunk sets the score assert [issue["relevant_file"].strip() for issue in review["key_issues_to_review"]] == ["a.py"] assert review["security_concerns"].startswith("SQL injection:") assert reviewer.review_chunk_count == 2 @@ -123,6 +130,66 @@ async def test_a_truncated_diff_is_reviewed_chunk_by_chunk_and_merged(chunking_e assert reviewer.remaining_files_list == ["still_left_out.py"] +@pytest.mark.asyncio +async def test_final_fit_clipping_marks_a_review_chunk_failed(chunking_enabled): + reviewer = _make_reviewer() + reviewer._raw_prompt_vars = None + reviewer.vars = {"diff": ""} + reviewer.ai_handler.chat_completion = AsyncMock(return_value=(CHUNK_A, "stop")) + + class SelectiveBudget: + def fit_prompt_variable(self, _variables, _name, optional_text, **_kwargs): + fitted_text = optional_text[:-1] if optional_text == "chunk-b" else optional_text + return SimpleNamespace( + optional_text=fitted_text, + system_prompt="system", + user_prompt="user", + ) + + with ( + patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["b.py"])), + patch( + "pr_agent.tools.pr_reviewer.get_pr_multi_diffs", + return_value=(["chunk-a", "chunk-b"], []), + ), + patch( + "pr_agent.tools.pr_reviewer.AttemptTokenBudget.for_attempt", + return_value=SelectiveBudget(), + ), + pytest.raises(ValueError, match="complete packed review diff"), + ): + await reviewer._prepare_prediction("model") + + assert list(reviewer._chunked_results) == [0] + assert reviewer.prediction is None + reviewer.ai_handler.chat_completion.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_chunked_review_reuses_the_prepared_diff_for_the_same_model_attempt(chunking_enabled): + reviewer = _make_reviewer() + reviewer._get_prediction = AsyncMock(side_effect=[CHUNK_A, CHUNK_B]) + prepared = PreparedPRDiff( + diff="first compressed diff", + remaining_files_list=["b.py"], + file_dict={"a.py": {"patch": "chunk-a", "tokens": 10}}, + ) + + with ( + patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=prepared) as get_pr_diff, + patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", + return_value=(["chunk-a", "chunk-b"], ["still_left_out.py"])) as get_pr_multi_diffs, + ): + await reviewer._prepare_prediction("model") + + assert get_pr_diff.call_args.kwargs["return_prepared"] is True + assert get_pr_multi_diffs.call_args.kwargs["prepared_diff"] is prepared + assert get_pr_diff.call_args.kwargs["output_token_reserve"] is reviewer.ai_handler.get_output_token_reserve + assert get_pr_multi_diffs.call_args.kwargs["output_token_reserve"] is reviewer.ai_handler.get_output_token_reserve + assert reviewer.review_chunk_count == 2 + assert reviewer.remaining_files_list == ["still_left_out.py"] + + @pytest.mark.asyncio async def test_max_number_of_calls_bounds_the_number_of_chunks(chunking_enabled): get_settings().set("pr_reviewer.max_number_of_calls", 7) @@ -166,30 +233,308 @@ async def test_a_chunk_that_fails_does_not_lose_the_chunks_that_succeeded(chunki patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["b.py"])), patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", return_value=(["chunk-a", "chunk-b"], [])), + pytest.raises(RuntimeError, match="model refused"), ): await reviewer._prepare_prediction("model") - assert reviewer.prediction_data["review"]["score"] == "40" + reviewer._get_prediction.side_effect = [CHUNK_A] + await reviewer._prepare_chunked_prediction("model") + + assert reviewer.prediction_data["review"]["score"] == 40 assert reviewer.review_chunk_count == 2 - assert reviewer.review_failed_chunk_count == 1 + assert reviewer.review_failed_chunk_count == 0 + assert [call.args[1] for call in reviewer._get_prediction.await_args_list] == [ + "chunk-a", "chunk-b", "chunk-a", + ] @pytest.mark.asyncio -async def test_an_empty_chunk_does_not_lose_a_valid_sibling_or_trigger_fallback(chunking_enabled): +async def test_a_failed_chunk_recovery_preserves_finding_state_lifecycle(chunking_enabled): + """Verify that a recovered chunked review participates in the finding-state lifecycle.""" reviewer = _make_reviewer() - reviewer._get_prediction = AsyncMock(side_effect=["review: {}", CHUNK_B]) + reviewer._get_prediction = AsyncMock(side_effect=[RuntimeError("model refused"), CHUNK_B]) with ( patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["b.py"])), patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", return_value=(["chunk-a", "chunk-b"], [])), + pytest.raises(RuntimeError, match="model refused"), ): await reviewer._prepare_prediction("model") - assert reviewer._get_prediction.await_count == 2 - assert reviewer.prediction_data["review"]["score"] == "40" - assert reviewer.review_chunk_count == 2 + reviewer._get_prediction.side_effect = [CHUNK_A] + await reviewer._prepare_chunked_prediction("model") + + previous_state = reconcile_review_findings( + None, + [{"path": "a.py", "body": "old finding", "line_start": 3, "line_end": 4}], + allow_resolution=False, + head_sha="head-1", + timestamp="2026-09-09T00:00:00+00:00", + ).state + reviewer._review_finding_state_enabled = lambda: True + reviewer._load_review_finding_state = lambda: ParsedReviewState( + previous_state, present=True, valid=True + ) + reviewer._review_head_sha = lambda: "head-2" + reviewer._review_run_id = lambda: "run-2" + + reviewer._prepare_review_finding_state(reviewer.prediction_data) + + assert reviewer._review_state_result is not None + assert reviewer._review_state_result.state["last_run"]["complete"] is True + assert reviewer._review_state_result.state["findings"][0]["state"] == "RESOLVED" + + +@pytest.mark.asyncio +async def test_a_malformed_chunk_is_retried_without_repeating_successful_chunks(chunking_enabled): + reviewer = _make_reviewer() + chunk_c = CHUNK_A.replace("a.py", "c.py").replace("the index is never checked", "the value is never checked") + reviewer._get_prediction = AsyncMock(side_effect=[CHUNK_A, "review: {}", chunk_c]) + + with ( + patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["b.py"])), + patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", + return_value=(["chunk-a", "chunk-b", "chunk-c"], [])), + pytest.raises(ValueError, match="non-empty review"), + ): + await reviewer._prepare_prediction("model") + + assert reviewer._get_prediction.await_count == 3 + assert reviewer.prediction_data is None + + reviewer._get_prediction.side_effect = [CHUNK_B] + await reviewer._prepare_chunked_prediction("model") + + assert reviewer.review_chunk_count == 3 + assert reviewer.review_failed_chunk_count == 0 + assert reviewer.prediction_data["review"]["score"] == 40 + issues = reviewer.prediction_data["review"]["key_issues_to_review"] + assert [issue["relevant_file"].strip() for issue in issues] == ["a.py", "c.py"] + assert [call.args[1] for call in reviewer._get_prediction.await_args_list] == [ + "chunk-a", "chunk-b", "chunk-c", "chunk-b", + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("has_successful_chunks", [True, False]) +async def test_exhausted_fallbacks_publish_partial_review_only_when_chunks_succeeded( + chunking_enabled, has_successful_chunks, +): + reviewer = _make_reviewer() + reviewer.vars = {} + reviewer.git_provider.should_publish_review_as_thread.return_value = False + reviewer.git_provider.supports_review_comment_identity.return_value = False + chunk_c = CHUNK_A.replace("a.py", "c.py").replace("the index is never checked", "the value is never checked") + primary = [CHUNK_A, "review: {}", chunk_c] if has_successful_chunks else ["review: {}"] * 3 + fallback = [RuntimeError("context limit exceeded")] * (1 if has_successful_chunks else 3) + reviewer._get_prediction = AsyncMock(side_effect=primary + fallback) + settings_values = { + "config.model": "primary", + "config.fallback_models": ["fallback"], + "config.publish_output": True, + "config.is_auto_command": False, + "config.propagate_tool_errors": False, + "pr_reviewer.persistent_comment": False, + "pr_reviewer.publish_error_details": False, + } + snapshot = snapshot_settings(settings_values) + try: + for key, value in settings_values.items(): + get_settings().set(key, value) + with ( + patch("pr_agent.tools.pr_reviewer.extract_and_cache_pr_tickets", new_callable=AsyncMock), + patch("pr_agent.tools.pr_reviewer.fit_related_tickets_to_prompt_budget", + return_value=({}, reviewer.token_handler)), + patch("pr_agent.algo.pr_processing.route_primary_model", return_value=None), + patch("pr_agent.algo.pr_processing._get_all_deployments", return_value=[None, None]), + patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["b.py"])), + patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", + return_value=(["chunk-a", "chunk-b", "chunk-c"], [])), + patch.object(reviewer, "_prepare_pr_review", side_effect=lambda: _render_review(reviewer)) as render, + ): + # Exercise the real fallback chain and run()'s terminal publication path. + await reviewer.run() + finally: + restore_settings(snapshot) + + calls = [call.args for call in reviewer._get_prediction.await_args_list] + assert calls[:3] == [("primary", "chunk-a"), ("primary", "chunk-b"), ("primary", "chunk-c")] + published = reviewer.git_provider.publish_comment.call_args.args[0] + if not has_successful_chunks: + render.assert_not_called() + assert published == "Failed to review PR" + assert reviewer.prediction_data is None + assert calls[3:] == [("fallback", chunk) for chunk in ("chunk-a", "chunk-b", "chunk-c")] + return + + assert calls[3:] == [("fallback", "chunk-b")] + assert reviewer.review_chunk_count == 3 assert reviewer.review_failed_chunk_count == 1 + assert reviewer.remaining_files_list == [] + assert "1 chunk(s) failed and are not covered by this review." in published + issues = reviewer.prediction_data["review"]["key_issues_to_review"] + assert [issue["relevant_file"].strip() for issue in issues] == ["a.py", "c.py"] + + previous_state = reconcile_review_findings( + None, [{"path": "b.py", "body": "old finding", "line_start": 3, "line_end": 4}], + allow_resolution=False, + ).state + reviewer._review_finding_state_enabled = lambda: True + reviewer._load_review_finding_state = lambda: ParsedReviewState(previous_state, present=True, valid=True) + reviewer._review_head_sha = lambda: "head-2" + reviewer._review_run_id = lambda: "run-2" + reviewer._prepare_review_finding_state(reviewer.prediction_data) + assert reviewer._review_state_result.state["last_run"]["complete"] is False + assert reviewer._review_state_result.state["findings"][0]["state"] != "RESOLVED" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("propagate_tool_errors", [True, False]) +async def test_exhausted_fallbacks_propagate_partial_review_failure_when_configured( + chunking_enabled, propagate_tool_errors, +): + reviewer = _make_reviewer() + reviewer.vars = {} + reviewer.git_provider.should_publish_review_as_thread.return_value = False + reviewer.git_provider.supports_review_comment_identity.return_value = False + chunk_c = CHUNK_A.replace("a.py", "c.py").replace("the index is never checked", "the value is never checked") + reviewer._get_prediction = AsyncMock(side_effect=[ + CHUNK_A, "review: {}", chunk_c, RuntimeError("context limit exceeded"), + ]) + settings_values = { + "config.model": "primary", + "config.fallback_models": ["fallback"], + "config.publish_output": True, + "config.is_auto_command": False, + "config.propagate_tool_errors": propagate_tool_errors, + "pr_reviewer.persistent_comment": False, + "pr_reviewer.publish_error_details": False, + } + snapshot = snapshot_settings(settings_values) + try: + for key, value in settings_values.items(): + get_settings().set(key, value) + with ( + patch("pr_agent.tools.pr_reviewer.extract_and_cache_pr_tickets", new_callable=AsyncMock), + patch("pr_agent.tools.pr_reviewer.fit_related_tickets_to_prompt_budget", + return_value=({}, reviewer.token_handler)), + patch("pr_agent.algo.pr_processing.route_primary_model", return_value=None), + patch("pr_agent.algo.pr_processing._get_all_deployments", return_value=[None, None]), + patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["b.py"])), + patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", + return_value=(["chunk-a", "chunk-b", "chunk-c"], [])), + patch.object(reviewer, "_prepare_pr_review", side_effect=lambda: _render_review(reviewer)) as render, + ): + if propagate_tool_errors: + with pytest.raises(Exception, match="Failed to generate prediction with any model"): + await reviewer.run() + else: + await reviewer.run() + finally: + restore_settings(snapshot) + + published = reviewer.git_provider.publish_comment.call_args.args[0] + assert "1 chunk(s) failed and are not covered by this review." in published + assert reviewer.review_failed_chunk_count == 1 + issues = reviewer.prediction_data["review"]["key_issues_to_review"] + assert [issue["relevant_file"].strip() for issue in issues] == ["a.py", "c.py"] + render.assert_called_once() + + +@pytest.mark.asyncio +async def test_cached_chunks_are_used_when_fallback_diff_fits(chunking_enabled): + reviewer = _make_reviewer() + reviewer._get_prediction = AsyncMock(side_effect=[CHUNK_A, RuntimeError("model refused"), CHUNK_B]) + + with ( + patch("pr_agent.tools.pr_reviewer.get_pr_diff", + side_effect=[("diff", ["b.py"]), ("full diff", [])]), + patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", + return_value=(["chunk-a", "chunk-b"], [])), + pytest.raises(RuntimeError, match="model refused"), + ): + await reviewer._prepare_prediction("model") + + await reviewer._prepare_chunked_prediction("model") + + assert reviewer.prediction_data["review"]["score"] == 40 + assert [call.args[1] for call in reviewer._get_prediction.await_args_list] == [ + "chunk-a", "chunk-b", "chunk-b", + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("fallback_fits", [True, False]) +async def test_larger_fallback_includes_omitted_files_without_repeating_successes(chunking_enabled, fallback_fits): + reviewer = _make_reviewer() + reviewer.token_handler.prompt_tokens = 0 + reviewer.token_handler.count_tokens.side_effect = len + get_settings().set("pr_reviewer.max_number_of_calls", 2) + chunk_a = "## File: 'a.py'\n+first change\n" + chunk_b = "## File: 'b.py'\n+second change\n" + chunk_c = "## File: 'c.py'\n+previously omitted change\n" + reviewer._get_prediction = AsyncMock(side_effect=[CHUNK_A, RuntimeError("model refused"), CHUNK_B]) + + with ( + patch("pr_agent.tools.pr_reviewer.get_pr_diff", side_effect=[ + (chunk_a, ["b.py", "c.py"]), + (chunk_a + chunk_b + chunk_c, []), + ]) as get_diff, + patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", + return_value=([chunk_a, chunk_b], ["c.py"])) as get_multi, + patch("pr_agent.algo.token_budget.get_max_tokens", + return_value=10000 if fallback_fits else 1500 + len(chunk_b)), + ): + with pytest.raises(RuntimeError, match="model refused"): + await reviewer._prepare_prediction("primary") + await reviewer._prepare_prediction("fallback") + + assert get_diff.call_count == 2 + get_multi.assert_called_once() + calls = reviewer._get_prediction.await_args_list + assert len(calls) == 3 + assert calls[0].args == ("primary", chunk_a) + assert calls[1].args == ("primary", chunk_b) + fallback_diff = calls[2].args[1] + assert calls[2].args[0] == "fallback" + assert chunk_a not in fallback_diff + assert chunk_b in fallback_diff + assert (chunk_c in fallback_diff) is fallback_fits + assert reviewer.remaining_files_list == ([] if fallback_fits else ["c.py"]) + assert reviewer.review_chunk_count == 2 + assert reviewer.review_failed_chunk_count == 0 + + +@pytest.mark.asyncio +async def test_a_smaller_fallback_splits_an_oversized_pending_chunk(chunking_enabled): + reviewer = _make_reviewer() + a_part = "## File: 'a.py'\n+change a\n" + b_part = "## File: 'blong.py'\n+long change b\n" + combined_chunk = a_part + b_part + chunk_c = "## File: 'c.py'\n+change c\n" + reviewer._get_prediction = AsyncMock(side_effect=[RuntimeError("model refused"), CHUNK_B]) + + with ( + patch("pr_agent.tools.pr_reviewer.get_pr_diff", + side_effect=[(combined_chunk, ["c.py", "blong.py"]), (combined_chunk, [])]), + patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", + return_value=([combined_chunk, chunk_c], [])), + patch("pr_agent.algo.token_budget.get_max_tokens", + side_effect=lambda model, **_kwargs: 10000 if model == "primary" else 1540), + ): + with pytest.raises(RuntimeError, match="model refused"): + await reviewer._prepare_prediction("primary") + reviewer._get_prediction.side_effect = [CHUNK_A, CHUNK_A] + await reviewer._prepare_prediction("fallback") + + assert reviewer.prediction_data["review"]["score"] == 40 + assert reviewer.review_chunk_count == 3 + assert reviewer.review_failed_chunk_count == 0 + assert [call.args for call in reviewer._get_prediction.await_args_list] == [ + ("primary", combined_chunk), ("primary", chunk_c), + ("fallback", a_part), ("fallback", b_part), + ] @pytest.mark.asyncio @@ -212,23 +557,51 @@ async def test_a_review_where_every_chunk_failed_raises_so_a_fallback_model_is_t ["not yaml at all", "nor is this"], ["review: {}", "review: {}"], ]) -async def test_chunks_without_nonempty_reviews_fall_back_to_a_single_call_review(chunking_enabled, - chunk_predictions): +async def test_chunks_without_nonempty_reviews_fail_the_model_attempt(chunking_enabled, + chunk_predictions): reviewer = _make_reviewer() - reviewer._get_prediction = AsyncMock(side_effect=[*chunk_predictions, CHUNK_A]) + reviewer._get_prediction = AsyncMock(side_effect=chunk_predictions) with ( patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["b.py"])), patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", return_value=(["chunk-a", "chunk-b"], [])), + pytest.raises(ValueError, match="non-empty review"), ): await reviewer._prepare_prediction("model") - assert reviewer._get_prediction.await_count == 3 - assert reviewer.prediction == CHUNK_A + assert reviewer._get_prediction.await_count == 2 assert reviewer.prediction_data is None +@pytest.mark.asyncio +async def test_invalid_chunk_emits_one_schema_warning_before_rendering(chunking_enabled): + reviewer = _make_reviewer() + reviewer._get_prediction = AsyncMock(side_effect=[ + "review:\n score: 101\n key_issues_to_review: []", + CHUNK_B, + ]) + + with ( + patch("pr_agent.tools.pr_reviewer.get_pr_diff", return_value=("diff", ["b.py"])), + patch("pr_agent.tools.pr_reviewer.get_pr_multi_diffs", + return_value=(["chunk-a", "chunk-b"], [])), + patch("pr_agent.tools.pr_reviewer.get_logger") as get_logger, + ): + await reviewer._prepare_prediction("model") + reviewer._prepare_pr_review() + + warnings = get_logger.return_value.warning.call_args_list + schema_warnings = [call for call in warnings if call.args == ("Review output failed schema validation",)] + assert len(schema_warnings) == 1 + assert schema_warnings[0].kwargs["artifact"] == {"field": "review.score", "value": 101} + # schema validation stays warn-only (#3372): the chunk is merged, not retried + assert reviewer._get_prediction.await_count == 2 + assert reviewer.review_chunk_count == 2 + assert reviewer.review_failed_chunk_count == 0 + assert reviewer.prediction_data["review"]["score"] == 40 + + def _render_review(reviewer): reviewer.prediction = "review:\n summary: test" reviewer.git_provider.get_diff_files.return_value = [] @@ -240,7 +613,7 @@ def _render_review(reviewer): patch("pr_agent.tools.pr_reviewer.github_action_output"), patch("pr_agent.tools.pr_reviewer.convert_to_markdown_v2", return_value="original review"), ): - return reviewer._prepare_pr_review() + return PRReviewer._prepare_pr_review(reviewer) def test_a_chunked_review_says_how_many_chunks_it_was_built_from(): diff --git a/tests/unittest/test_review_persistent_comment_authorship.py b/tests/unittest/test_review_persistent_comment_authorship.py index f457ab2467..1f7669363a 100644 --- a/tests/unittest/test_review_persistent_comment_authorship.py +++ b/tests/unittest/test_review_persistent_comment_authorship.py @@ -17,11 +17,11 @@ from unittest.mock import MagicMock import pytest -from github import GithubException +from github import Auth, GithubException +from pr_agent.algo.comment_identity import PRReviewHeader, PRReviewIdentity, add_pr_review_identity from pr_agent.algo.review_finding_state import append_review_state, reconcile_review_findings from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo -from pr_agent.algo.utils import PRReviewHeader, PRReviewIdentity, add_pr_review_identity from pr_agent.config_loader import get_settings from pr_agent.git_providers.azuredevops_provider import AzureDevopsProvider from pr_agent.git_providers.github_provider import GithubProvider @@ -367,9 +367,11 @@ def test_a_transient_app_login_failure_is_retried(monkeypatch): monkeypatch.setattr(get_settings(), "github", SimpleNamespace( app_id="1", private_key="key", deployment_type="app"), raising=False) attempts = [] + auths = [] def integration(**kwargs): attempts.append(1) + auths.append(kwargs["auth"]) if len(attempts) == 1: raise RuntimeError("connection reset") return SimpleNamespace(get_app=lambda: SimpleNamespace(slug="pr-agent")) @@ -379,6 +381,7 @@ def integration(**kwargs): assert provider._resolve_app_login() == "" assert provider._resolve_app_login() == "pr-agent[bot]" assert len(attempts) == 2 + assert all(isinstance(auth, Auth.AppAuth) for auth in auths) def test_a_resolved_app_login_is_cached(monkeypatch): @@ -389,9 +392,11 @@ def test_a_resolved_app_login_is_cached(monkeypatch): monkeypatch.setattr(get_settings(), "github", SimpleNamespace( app_id="1", private_key="key", deployment_type="app"), raising=False) attempts = [] + auths = [] def integration(**kwargs): attempts.append(1) + auths.append(kwargs["auth"]) return SimpleNamespace(get_app=lambda: SimpleNamespace(slug="pr-agent")) monkeypatch.setattr("pr_agent.git_providers.github_provider.GithubIntegration", integration) @@ -399,6 +404,7 @@ def integration(**kwargs): assert provider._resolve_app_login() == "pr-agent[bot]" assert provider._resolve_app_login() == "pr-agent[bot]" assert len(attempts) == 1 + assert all(isinstance(auth, Auth.AppAuth) for auth in auths) def test_an_app_that_never_resolves_stays_unproven(monkeypatch): diff --git a/tests/unittest/test_run_details_wiring.py b/tests/unittest/test_run_details_wiring.py index b9b5684830..5f47fc0c26 100644 --- a/tests/unittest/test_run_details_wiring.py +++ b/tests/unittest/test_run_details_wiring.py @@ -4,6 +4,7 @@ import pytest from pr_agent.algo.run_details import init_run_details, record_ai_call, record_model_used +from pr_agent.algo.types import FilePatchInfo from pr_agent.config_loader import get_settings from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions from pr_agent.tools.pr_description import PRDescription @@ -137,7 +138,7 @@ async def test_pr_description_appends_run_details_only_when_enabled(monkeypatch) description.git_provider.is_supported.side_effect = lambda cap: cap == "gfm_markdown" description._prepare_data = MagicMock() - description._prepare_pr_answer = MagicMock(return_value=("AI title", "Base description body", "", [])) + description._prepare_pr_answer = MagicMock(return_value=("AI title", "Base description body", "")) monkeypatch.setattr("pr_agent.tools.pr_description.init_run_details", _seeded_init_run_details) monkeypatch.setattr("pr_agent.tools.pr_description.extract_and_cache_pr_tickets", _noop_async) @@ -176,10 +177,21 @@ async def test_pr_code_suggestions_appends_run_details_only_when_enabled(monkeyp suggestions.git_provider = MagicMock() suggestions.git_provider.get_files.return_value = ["changed.py"] suggestions.git_provider.is_supported.side_effect = lambda cap: cap == "gfm_markdown" + suggestions.git_provider.diff_files = [ + FilePatchInfo( + base_file="old()\n", + head_file="new()\n", + patch="@@ -1,1 +1,1 @@\n-old()\n+new()\n", + filename="changed.py", + ) + ] suggestions.generate_summarized_suggestions = MagicMock(return_value="Base suggestions body") async def _fake_retry(*_args, **_kwargs): - return {"code_suggestions": [{"label": "style"}]} + return {"code_suggestions": [ + {"label": "style", "relevant_file": "changed.py", + "relevant_lines_start": 1, "relevant_lines_end": 1}, + ]} monkeypatch.setattr("pr_agent.tools.pr_code_suggestions.init_run_details", _seeded_init_run_details) monkeypatch.setattr("pr_agent.tools.pr_code_suggestions.retry_with_fallback_models", _fake_retry) diff --git a/tests/unittest/test_run_status_acknowledgment.py b/tests/unittest/test_run_status_acknowledgment.py new file mode 100644 index 0000000000..1d35fa72e6 --- /dev/null +++ b/tests/unittest/test_run_status_acknowledgment.py @@ -0,0 +1,390 @@ +"""An automatic command should say it started before it has anything to publish. + +Automatic commands suppress the "Preparing review..." progress comment, so between opening a +pull request and the model answering there is no sign PR-Agent picked it up. When +`github.publish_as_check_run` is on, the tool's check run is opened as in_progress before the +command runs and completed in place by the tool, so there is one signal on the commit. +""" +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +import pr_agent.servers.github_app as github_app +from pr_agent.algo.run_details import command_failed, init_run_details, record_command_failure +from pr_agent.config_loader import get_settings +from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.tools.pr_reviewer import PRReviewer +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +API_URL = "https://api.github.com/repos/org/repo/pulls/1" +CHECK_RUNS_URL = "https://api.github.com/repos/org/repo/check-runs" + + +@pytest.fixture +def check_runs_enabled(): + snapshot = snapshot_settings(["github.publish_as_check_run"]) + get_settings().set("github.publish_as_check_run", True) + yield + restore_settings(snapshot) + + +def _github(sha="abc123", existing=None): + provider = GithubProvider.__new__(GithubProvider) + provider.repo = "org/repo" + provider.base_url = "https://api.github.com" + provider.last_commit_id = SimpleNamespace(sha=sha) if sha else None + provider._check_run_ids = {} + provider._check_runs_in_progress = set() + requester = MagicMock() + + def request(method, url, **kwargs): + if method == "GET": + return {}, {"check_runs": existing or []} + if method == "POST": + return {}, {"id": 101} + return {}, {} + + requester.requestJsonAndCheck.side_effect = request + provider.pr = SimpleNamespace(_requester=requester) + return provider + + +def _requests(provider): + return [(c.args[0], c.args[1], c.kwargs["input"]) for c in provider.pr._requester.requestJsonAndCheck.call_args_list + if c.args[0] != "GET"] + + +# -------------------------------------------------------------------------------------- +# The provider: open in_progress, complete in place +# -------------------------------------------------------------------------------------- +def test_start_check_run_creates_it_in_progress(): + provider = _github() + + assert provider.start_check_run("review", "PR-Agent is running /review") is True + + [(method, url, body)] = _requests(provider) + assert (method, url) == ("POST", CHECK_RUNS_URL) + assert body["name"] == "PR Agent - Review" + assert body["head_sha"] == "abc123" + assert body["status"] == "in_progress" + assert "conclusion" not in body + assert body["output"] == {"title": "PR Agent - Review", "summary": "PR-Agent is running /review"} + assert provider._check_runs_in_progress == {"review"} + + +def test_start_check_run_reopens_the_run_already_on_the_commit(): + """A re-run on the same head updates the existing run rather than adding a second one.""" + provider = _github(existing=[{"name": "PR Agent - Review", "id": 55}]) + + provider.start_check_run("review", "working") + + [(method, url, body)] = _requests(provider) + assert (method, url) == ("PATCH", f"{CHECK_RUNS_URL}/55") + assert body["status"] == "in_progress" + + +def test_the_tool_completes_the_run_the_runner_opened(): + provider = _github() + provider.start_check_run("review", "working") + + assert provider._publish_check_run("## Review\n\nlooks fine", "review") is True + + assert [(m, u) for m, u, _ in _requests(provider)] == [("POST", CHECK_RUNS_URL), ("PATCH", f"{CHECK_RUNS_URL}/101")] + completed = _requests(provider)[-1][2] + assert completed["status"] == "completed" + assert completed["conclusion"] == "neutral" + assert completed["output"]["text"] == "## Review\n\nlooks fine" + # The tool owns the run now: the runner's completion must not overwrite its output. + assert provider.finish_check_run("review", "failure", "could not finish") is False + assert len(_requests(provider)) == 2 + + +def test_finish_check_run_completes_a_run_the_tool_left_open(): + provider = _github() + provider.start_check_run("review", "working") + + assert provider.finish_check_run("review", "failure", "PR-Agent could not finish /review") is True + + completed = _requests(provider)[-1] + assert completed[:2] == ("PATCH", f"{CHECK_RUNS_URL}/101") + assert completed[2] == { + "status": "completed", + "conclusion": "failure", + "output": {"title": "PR Agent - Review", "summary": "PR-Agent could not finish /review"}, + } + assert provider._check_runs_in_progress == set() + + +def test_finish_check_run_ignores_a_run_it_did_not_open(): + provider = _github() + + assert provider.finish_check_run("review", "success", "done") is False + assert _requests(provider) == [] + + +def test_start_check_run_without_a_commit_sha_reports_failure(): + provider = _github(sha=None) + + assert provider.start_check_run("review", "working") is False + assert provider._check_runs_in_progress == set() + + +def test_start_check_run_survives_an_api_failure(): + provider = _github() + provider.pr._requester.requestJsonAndCheck.side_effect = RuntimeError("boom") + + assert provider.start_check_run("review", "working") is False + assert provider._check_runs_in_progress == set() + + +# -------------------------------------------------------------------------------------- +# Wiring: the automatic-command runner +# -------------------------------------------------------------------------------------- +@pytest.fixture +def auto_commands(monkeypatch): + settings = get_settings() + snapshot = snapshot_settings(["github_app.feedback_on_draft_pr", "config.disable_auto_feedback"]) + provider = MagicMock() + monkeypatch.setattr(github_app, "get_git_provider_with_context", lambda pr_url: provider) + monkeypatch.setattr(github_app, "get_pr_commands", lambda name: ["/review"]) + monkeypatch.setattr(github_app, "should_process_pr_logic", lambda body: True) + monkeypatch.setattr(github_app, "prepare_command", lambda command: command) + settings.set("github_app.feedback_on_draft_pr", True) + settings.set("config.disable_auto_feedback", False) + yield provider + restore_settings(snapshot) + + +@pytest.fixture +def restored_config(): + """Override a `config` key and put the original back through the same API.""" + settings = get_settings() + original = {} + + def _set(key, value): + original.setdefault(key, settings.get(f"config.{key}", None)) + settings.set(f"config.{key}", value) + + yield _set + for key, value in original.items(): + settings.set(f"config.{key}", value) + + +def _agent(outcome): + agent = MagicMock() + events = [] + + async def handle_request(api_url, command, notify=None): + events.append(("run", command)) + if isinstance(outcome, Exception): + raise outcome + if callable(outcome): + return outcome(command) + return outcome + + agent.handle_request = handle_request + return agent, events + + +def _record(provider, events): + provider.start_check_run.side_effect = lambda name, summary: events.append(("start", name, summary)) + provider.finish_check_run.side_effect = ( + lambda name, conclusion, summary: events.append(("finish", name, conclusion, summary))) + + +async def test_a_command_opens_its_check_run_before_running_and_completes_it_after(check_runs_enabled, auto_commands): + agent, events = _agent(True) + _record(auto_commands, events) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events == [ + ("start", "review", "PR-Agent is running /review"), + ("run", "/review"), + ("finish", "review", "success", "PR-Agent ran /review"), + ] + assert result is True + + +async def test_a_failed_command_completes_its_check_run_as_failure(check_runs_enabled, auto_commands): + agent, events = _agent(False) + _record(auto_commands, events) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events[-1] == ("finish", "review", "failure", "PR-Agent could not finish /review") + assert result is False + + +async def test_a_raising_command_completes_its_check_run_as_failure(check_runs_enabled, auto_commands): + agent, events = _agent(RuntimeError("boom")) + _record(auto_commands, events) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events[-1] == ("finish", "review", "failure", "PR-Agent could not finish /review") + assert result is False + + +async def test_each_command_gets_its_own_check_run(check_runs_enabled, auto_commands, monkeypatch): + monkeypatch.setattr(github_app, "get_pr_commands", + lambda name: ["/describe --pr_description.final_update_message=false", "/review", "/improve"]) + agent, events = _agent(True) + _record(auto_commands, events) + + await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert [e[:2] for e in events if e[0] != "run"] == [ + ("start", "describe"), ("finish", "describe"), + ("start", "review"), ("finish", "review"), + ("start", "suggestions"), ("finish", "suggestions"), + ] + + +async def test_a_command_without_a_check_run_opens_none(check_runs_enabled, auto_commands, monkeypatch): + monkeypatch.setattr(github_app, "get_pr_commands", lambda name: ["/ask something"]) + agent, events = _agent(True) + _record(auto_commands, events) + + await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events == [("run", "/ask something")] + + +async def test_nothing_is_opened_when_the_setting_is_off(auto_commands): + """Control: the shipped default changes nothing.""" + snapshot = snapshot_settings(["github.publish_as_check_run"]) + get_settings().set("github.publish_as_check_run", False) + try: + agent, events = _agent(True) + + await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + finally: + restore_settings(snapshot) + + auto_commands.start_check_run.assert_not_called() + auto_commands.finish_check_run.assert_not_called() + assert events == [("run", "/review")] + + +async def test_a_failing_acknowledgement_does_not_stop_the_command(check_runs_enabled, auto_commands): + """The check run is a courtesy: a provider that cannot write it must not cost the review.""" + auto_commands.start_check_run.side_effect = RuntimeError("no checks: write") + auto_commands.finish_check_run.side_effect = RuntimeError("no checks: write") + agent, events = _agent(True) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events == [("run", "/review")] + assert result is True + + +async def test_a_provider_that_cannot_be_built_does_not_stop_the_command( + check_runs_enabled, auto_commands, monkeypatch): + def explode(pr_url): + raise ValueError("Failed to get git provider") + + monkeypatch.setattr(github_app, "get_git_provider_with_context", explode) + agent, events = _agent(True) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events == [("run", "/review")] + assert result is True + + +async def test_a_provider_without_check_runs_is_left_alone(check_runs_enabled, auto_commands, monkeypatch): + """Selected by capability, not by provider type: anything without `start_check_run` is skipped.""" + monkeypatch.setattr(github_app, "get_git_provider_with_context", lambda pr_url: MagicMock(spec=[])) + agent, events = _agent(True) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events == [("run", "/review")] + assert result is True + + +# -------------------------------------------------------------------------------------- +# A tool that swallows its own error must not be reported as a success. +# +# `propagate_tool_errors` is false by default, so `PRReviewer.run()` logs the failure and +# returns normally. `handle_request` therefore answers True, and without the run-details +# verdict the check run would complete as a success on a pull request that got no review. +# -------------------------------------------------------------------------------------- +async def test_a_swallowed_tool_error_completes_the_check_run_as_failure(check_runs_enabled, auto_commands): + def swallow(command): + init_run_details() + record_command_failure() + return True + + agent, events = _agent(swallow) + _record(auto_commands, events) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events[-1] == ("finish", "review", "failure", "PR-Agent could not finish /review") + assert result is False + + +async def test_a_verdict_does_not_leak_into_the_next_command(check_runs_enabled, auto_commands, monkeypatch): + """The collector is a ContextVar, so a stale failure must not condemn the command after it.""" + monkeypatch.setattr(github_app, "get_pr_commands", lambda name: ["/describe", "/review"]) + + def outcome(command): + if command == "/describe": + init_run_details() + record_command_failure() + # A tool that never installs a collector of its own must read no verdict. + return True + + agent, events = _agent(outcome) + _record(auto_commands, events) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert [e for e in events if e[0] == "run"] == [("run", "/describe"), ("run", "/review")] + assert [e for e in events if e[0] == "finish"] == [ + ("finish", "describe", "failure", "PR-Agent could not finish /describe"), + ("finish", "review", "success", "PR-Agent ran /review"), + ] + assert result is False + assert command_failed() is False + + +async def test_a_clean_run_still_reports_success(check_runs_enabled, auto_commands): + """Control: a tool that installs a collector and records nothing is a success.""" + def clean(command): + init_run_details() + return True + + agent, events = _agent(clean) + _record(auto_commands, events) + + result = await github_app._perform_auto_commands_github("pr_commands", agent, {}, API_URL, {}) + + assert events[-1] == ("finish", "review", "success", "PR-Agent ran /review") + assert result is True + + +async def test_the_reviewer_records_a_swallowed_failure(monkeypatch, restored_config): + """End to end through the real `PRReviewer.run()`, with the shipped default settings.""" + restored_config("propagate_tool_errors", False) + restored_config("publish_output", False) + tool = PRReviewer.__new__(PRReviewer) + tool.git_provider = MagicMock() + tool.incremental = SimpleNamespace(is_incremental=False) + tool.pr_url = API_URL + monkeypatch.setattr(PRReviewer, "_prepare_prediction", MagicMock(side_effect=RuntimeError("boom"))) + + init_run_details() + await tool.run() + + assert command_failed() is True + + +async def test_a_reviewer_run_that_works_records_nothing(monkeypatch): + """Control: the flag is only set by the swallow path.""" + init_run_details() + + assert command_failed() is False diff --git a/tests/unittest/test_secret_provider_factory.py b/tests/unittest/test_secret_provider_factory.py index edd1d015c9..6fad0147a0 100644 --- a/tests/unittest/test_secret_provider_factory.py +++ b/tests/unittest/test_secret_provider_factory.py @@ -54,7 +54,9 @@ def test_get_secret_provider_google_cloud_storage(self): settings.config.secret_provider = "google_cloud_storage" mock_get_settings.return_value = settings - with patch('pr_agent.secret_providers.google_cloud_storage_secret_provider.GoogleCloudStorageSecretProvider') as MockProvider: + with patch( + "pr_agent.secret_providers.google_cloud_storage_secret_provider.GoogleCloudStorageSecretProvider" + ) as MockProvider: mock_instance = MagicMock() MockProvider.return_value = mock_instance @@ -69,7 +71,9 @@ def test_get_secret_provider_aws_secrets_manager(self): settings.config.secret_provider = "aws_secrets_manager" mock_get_settings.return_value = settings - with patch('pr_agent.secret_providers.aws_secrets_manager_provider.AWSSecretsManagerProvider') as MockProvider: + with patch( + "pr_agent.secret_providers.aws_secrets_manager_provider.AWSSecretsManagerProvider" + ) as MockProvider: mock_instance = MagicMock() MockProvider.return_value = mock_instance @@ -94,7 +98,9 @@ def test_get_secret_provider_initialization_error(self): settings.config.secret_provider = "aws_secrets_manager" mock_get_settings.return_value = settings - with patch('pr_agent.secret_providers.aws_secrets_manager_provider.AWSSecretsManagerProvider') as MockProvider: + with patch( + "pr_agent.secret_providers.aws_secrets_manager_provider.AWSSecretsManagerProvider" + ) as MockProvider: MockProvider.side_effect = Exception("Initialization failed") with pytest.raises(ValueError, match="Failed to initialize aws_secrets_manager secret provider"): diff --git a/tests/unittest/test_should_process_pr_logic_shared.py b/tests/unittest/test_should_process_pr_logic_shared.py new file mode 100644 index 0000000000..99ce062ba2 --- /dev/null +++ b/tests/unittest/test_should_process_pr_logic_shared.py @@ -0,0 +1,200 @@ +"""Pin one filtering policy across the five webhook servers. + +Read each test as the same question asked of every server: should this pull request be +processed at all. #3516 moved the rules into `pr_agent/servers/utils.py`, so drive the +servers through their own entry points rather than the helper, and keep them agreeing +rule by rule — that is what stops the five copies coming back. + +Cover the string-valued `config.ignore_pr_title` case too: iterating "^WIP" yields "^", +which matches every title, so a setting written as a plain string must be treated as one +pattern on every provider. +""" + +import copy +import importlib + +import pytest + +from pr_agent.config_loader import get_settings +from pr_agent.servers import bitbucket_app, bitbucket_server_webhook, gitea_app, github_app +from pr_agent.servers.utils import should_process_pr_logic as shared_should_process_pr_logic +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +IGNORE_KEYS = ( + "CONFIG.IGNORE_REPOSITORIES", + "CONFIG.IGNORE_PR_AUTHORS", + "CONFIG.IGNORE_PR_TITLE", + "CONFIG.IGNORE_PR_LABELS", + "CONFIG.IGNORE_PR_SOURCE_BRANCHES", + "CONFIG.IGNORE_PR_TARGET_BRANCHES", +) + +DEFAULTS = { + "title": "Regular PR", + "sender": "alice", + "source_branch": "feature/cache", + "target_branch": "main", + "repo_full_name": "org/repo", + "labels": (), +} + + +def _fields(**overrides): + fields = dict(DEFAULTS) + fields.update(overrides) + return fields + + +def _github_body(fields): + return { + "pull_request": { + "title": fields["title"], + "labels": [{"name": label} for label in fields["labels"]], + "head": {"ref": fields["source_branch"]}, + "base": {"ref": fields["target_branch"]}, + }, + "sender": {"login": fields["sender"]}, + "repository": {"full_name": fields["repo_full_name"]}, + } + + +def _bitbucket_payload(fields): + return { + "data": { + "pullrequest": { + "title": fields["title"], + "source": {"branch": {"name": fields["source_branch"]}}, + "destination": { + "branch": {"name": fields["target_branch"]}, + "repository": {"full_name": fields["repo_full_name"]}, + }, + }, + "actor": {"display_name": fields["sender"]}, + } + } + + +def _bitbucket_server_payload(fields): + project_key, _, repo_slug = fields["repo_full_name"].partition("/") + return { + "pullRequest": { + "id": 7, + "title": fields["title"], + "fromRef": {"displayId": fields["source_branch"]}, + "toRef": { + "displayId": fields["target_branch"], + "repository": {"slug": repo_slug, "project": {"key": project_key}}, + }, + "author": {"user": {"name": fields["sender"]}}, + } + } + + +def _gitlab_payload(fields): + return { + "object_attributes": { + "title": fields["title"], + "source_branch": fields["source_branch"], + "target_branch": fields["target_branch"], + "labels": [{"title": label} for label in fields["labels"]], + }, + "project": {"path_with_namespace": fields["repo_full_name"]}, + "user": {"username": fields["sender"]}, + } + + +@pytest.fixture(scope="module") +def gitlab_webhook_module(): + settings = get_settings() + original_git_provider = settings.config.get("git_provider", None) + had_gitlab_settings = "GITLAB" in settings + original_gitlab_settings = copy.deepcopy(settings.get("GITLAB", None)) + settings.set("GITLAB.URL", "https://gitlab.com") + try: + yield importlib.import_module("pr_agent.servers.gitlab_webhook") + finally: + settings.config.git_provider = original_git_provider + if had_gitlab_settings: + settings.set("GITLAB", original_gitlab_settings) + else: + settings.unset("GITLAB", force=True) + + +@pytest.fixture +def providers(gitlab_webhook_module): + """Yield one (name, entry point, payload builder, carries labels) row per webhook server.""" + return ( + ("github", github_app.should_process_pr_logic, _github_body, True), + ("gitea", gitea_app.should_process_pr_logic, _github_body, True), + ("gitlab", gitlab_webhook_module.should_process_pr_logic, _gitlab_payload, True), + ("bitbucket", bitbucket_app.should_process_pr_logic, _bitbucket_payload, False), + ("bitbucket_server", bitbucket_server_webhook.should_process_pr_logic, _bitbucket_server_payload, False), + ) + + +@pytest.fixture +def ignore_settings(): + snapshot = snapshot_settings(IGNORE_KEYS) + settings = get_settings() + for key in IGNORE_KEYS: + settings.set(key, []) + yield settings + restore_settings(snapshot) + + +def _assert_all(providers, fields, expected, reason, only_with_labels=False): + for name, should_process, build_payload, carries_labels in providers: + if only_with_labels and not carries_labels: + continue + result = should_process(build_payload(fields)) + assert result is expected, f"{name}: expected {expected} for {reason}, got {result}" + + +def test_unfiltered_pr_is_processed_by_every_server(providers, ignore_settings): + _assert_all(providers, _fields(), True, "a PR that matches no ignore rule") + + +@pytest.mark.parametrize( + "setting, patterns, matching, non_matching", + [ + ("CONFIG.IGNORE_REPOSITORIES", ["^org/repo$"], {"repo_full_name": "org/repo"}, + {"repo_full_name": "org/other"}), + ("CONFIG.IGNORE_PR_AUTHORS", ["^dependabot"], {"sender": "dependabot[bot]"}, {"sender": "alice"}), + ("CONFIG.IGNORE_PR_TITLE", ["^WIP"], {"title": "WIP: cache"}, {"title": "Add cache"}), + ("CONFIG.IGNORE_PR_SOURCE_BRANCHES", ["^generated/"], {"source_branch": "generated/api"}, + {"source_branch": "feature/api"}), + ("CONFIG.IGNORE_PR_TARGET_BRANCHES", ["^legacy$"], {"target_branch": "legacy"}, {"target_branch": "main"}), + ], +) +def test_every_server_applies_the_same_ignore_rule(providers, ignore_settings, setting, patterns, matching, + non_matching): + ignore_settings.set(setting, patterns) + _assert_all(providers, _fields(**matching), False, f"{setting}={patterns} matching {matching}") + _assert_all(providers, _fields(**non_matching), True, f"{setting}={patterns} not matching {non_matching}") + + +def test_label_rule_is_shared_by_the_servers_that_carry_labels(providers, ignore_settings): + ignore_settings.set("CONFIG.IGNORE_PR_LABELS", ["skip-pr-agent"]) + _assert_all(providers, _fields(labels=("skip-pr-agent",)), False, "an ignored label", only_with_labels=True) + _assert_all(providers, _fields(labels=("ready",)), True, "a label that is not ignored", only_with_labels=True) + + +def test_ignore_pr_title_given_as_a_plain_string_is_not_iterated_per_character(providers, ignore_settings): + """Read a string setting as one pattern, never as a sequence of characters. + + Iterating "^WIP" yields "^", which matches every title, so an unrelated PR would be + dropped. Dynaconf hands the value through unchanged when it is written as a string. + """ + ignore_settings.set("CONFIG.IGNORE_PR_TITLE", "^WIP") + _assert_all(providers, _fields(title="WIP: cache"), False, "a string ignore_pr_title that matches") + _assert_all(providers, _fields(title="Add cache"), True, "a string ignore_pr_title that does not match") + + +def test_an_invalid_pattern_does_not_filter_out_every_pull_request(providers, ignore_settings): + """Process the PR when a rule cannot be evaluated; the opposite silently disables the agent.""" + ignore_settings.set("CONFIG.IGNORE_PR_TITLE", ["("]) + _assert_all(providers, _fields(), True, "an unparsable ignore_pr_title regex") + + +def test_shared_helper_defaults_to_processing(ignore_settings): + assert shared_should_process_pr_logic() is True diff --git a/tests/unittest/test_show_relevant_configurations.py b/tests/unittest/test_show_relevant_configurations.py index 25d3e9320b..7f4004486b 100644 --- a/tests/unittest/test_show_relevant_configurations.py +++ b/tests/unittest/test_show_relevant_configurations.py @@ -2,7 +2,7 @@ import pytest -from pr_agent.algo.utils import show_relevant_configurations +from pr_agent.algo.run_output import show_relevant_configurations from pr_agent.config_loader import get_settings diff --git a/tests/unittest/test_show_run_details.py b/tests/unittest/test_show_run_details.py index a462fd1a84..2f901eecb7 100644 --- a/tests/unittest/test_show_run_details.py +++ b/tests/unittest/test_show_run_details.py @@ -5,7 +5,7 @@ from pr_agent.algo import run_details from pr_agent.algo.run_details import get_run_details, init_run_details, record_ai_call, record_model_used -from pr_agent.algo.utils import show_run_details +from pr_agent.algo.run_output import show_run_details from pr_agent.config_loader import get_settings diff --git a/tests/unittest/test_similar_issue_embeddings.py b/tests/unittest/test_similar_issue_embeddings.py index a09e880a24..b72c9eaf4a 100644 --- a/tests/unittest/test_similar_issue_embeddings.py +++ b/tests/unittest/test_similar_issue_embeddings.py @@ -1,6 +1,5 @@ """Embed with the openai>=1.0 client that requirements.txt pins.""" import inspect -import sys from types import SimpleNamespace import pytest @@ -102,7 +101,7 @@ def __init__(self, api_key=None): @pytest.mark.asyncio -async def test_pinecone_query_import_is_available_in_run(monkeypatch): +async def test_pinecone_query_branch_runs(monkeypatch): queried = [] class FakeIndex: @@ -110,7 +109,6 @@ def query(self, *args, **kwargs): queried.append(kwargs) return SimpleNamespace(to_dict=lambda: {"matches": []}) - monkeypatch.setitem(sys.modules, "pinecone", SimpleNamespace(Index=lambda **kwargs: FakeIndex())) monkeypatch.setattr(psi, "_embed", lambda texts: [[0.5]]) monkeypatch.setattr( psi, @@ -127,6 +125,8 @@ def query(self, *args, **kwargs): tool.issue_url = "https://github.com/example/repo/issues/1" tool.index_name = "issues" tool.repo_name_for_index = "example-repo" + tool.pinecone_namespace = psi._pinecone_namespace("example/repo") + tool.pc = SimpleNamespace(Index=lambda name: FakeIndex()) tool.git_provider = SimpleNamespace( _parse_issue_url=lambda url: ("example/repo", 1), repo_obj=SimpleNamespace(get_issue=lambda number: issue), @@ -134,3 +134,5 @@ def query(self, *args, **kwargs): assert await tool.run() is None assert queried, "run() never entered the pinecone branch" + assert queried[0]["vector"] == [0.5] + assert queried[0]["namespace"] == psi._pinecone_namespace("example/repo") diff --git a/tests/unittest/test_similar_issue_hit_register.py b/tests/unittest/test_similar_issue_hit_register.py new file mode 100644 index 0000000000..04fca5c79b --- /dev/null +++ b/tests/unittest/test_similar_issue_hit_register.py @@ -0,0 +1,38 @@ +"""The similar-issue hit register keeps the three result lists aligned per issue.""" +from pr_agent.tools.pr_similar_issue import PRSimilarIssue + + +def _drive(records): + issues, comments, scores = [], [], [] + for issue_number, comment_number, score in records: + PRSimilarIssue._record_similar_hit( + issues, comments, scores, issue_number, comment_number, score + ) + return issues, comments, scores + + +def test_first_and_best_scored_hit_per_issue_wins(): + issues, comments, scores = _drive([(7, -1, 0.79), (7, 0, 0.72), (8, -1, 0.66)]) + + assert issues == [7, 8] + assert comments == [-1, -1] + assert scores == ["0.79", "0.66"] + + +def test_comment_hit_before_body_hit_is_kept(): + issues, comments, scores = _drive([(7, 0, 0.90), (7, -1, 0.80)]) + + assert issues == [7] + assert comments == [0] + assert scores == ["0.90"] + + +def test_lists_stay_aligned_across_interleaved_issues(): + issues, comments, scores = _drive( + [(7, -1, 0.5), (8, 2, 0.4), (7, 1, 0.3), (9, -1, 0.2), (8, -1, 0.1)] + ) + + assert issues == [7, 8, 9] + assert comments == [-1, 2, -1] + assert scores == ["0.50", "0.40", "0.20"] + assert len(issues) == len(comments) == len(scores) diff --git a/tests/unittest/test_similar_issue_lancedb_ingest.py b/tests/unittest/test_similar_issue_lancedb_ingest.py new file mode 100644 index 0000000000..9748f49769 --- /dev/null +++ b/tests/unittest/test_similar_issue_lancedb_ingest.py @@ -0,0 +1,619 @@ +"""The LanceDB ingest path adds new rows to an existing index instead of skipping them.""" +import sys +import time +import types +from types import SimpleNamespace + +import pytest + +from pr_agent.tools.pr_similar_issue import PRSimilarIssue + + +class FakeDB: + def __init__(self, tables): + self._tables = tables + self.table = None + + def list_tables(self): + return SimpleNamespace(tables=self._tables) + + def __getitem__(self, name): + if name not in self._tables: + raise KeyError(name) + return self.table + + +class _FakeDataFrame: + def __init__(self, documents): + self.records = [dict(doc) for doc in documents] + + def __len__(self): + return len(self.records) + + def __getitem__(self, key): + return _FakeSeries([record[key] for record in self.records]) + + def __setitem__(self, key, value): + for i, record in enumerate(self.records): + record[key] = value[i] + + def to_dict(self, orient="records"): + return self.records + + +class _FakeSeries: + def __init__(self, values): + self._values = values + + def to_list(self): + return self._values + + +def _install_fake_pandas(monkeypatch): + fake_pandas = types.ModuleType("pandas") + fake_pandas.DataFrame = _FakeDataFrame + monkeypatch.setitem(sys.modules, "pandas", fake_pandas) + + +def _fake_embed(texts): + return [[0.0] * 8] * len(texts) + + +def _fake_issue(number=5): + return SimpleNamespace( + number=number, + title="a title", + body="a body", + pull_request=False, + user=SimpleNamespace(login="tester"), + created_at="2026-01-01T00:00:00Z", + ) + + +def _make_tool(monkeypatch, fake_db): + _install_fake_pandas(monkeypatch) + monkeypatch.setattr("pr_agent.tools.pr_similar_issue._embed_with_fallback", _fake_embed) + tool = PRSimilarIssue.__new__(PRSimilarIssue) + tool.db = fake_db + tool.index_name = "codium-ai-pr-agent-issues" + tool.max_issues_to_scan = 10 + tool.token_handler = SimpleNamespace(count_tokens=lambda _: 0) + tool.table = fake_db.table + tool._process_issue = lambda issue: ( + f"title: {issue.title}\nbody: {issue.body}", + [], + issue.number, + ) + return tool + + +class _FakeSearch: + def __init__(self, table): + self._table = table + self._id = None + + def limit(self, _n): + return self + + def where(self, sql): + self._id = sql.split("'")[1] + return self + + def to_list(self): + return [row for row in self._table.rows if row["id"] == self._id] + + +def _fake_add(table, df): + table.add_calls.append(len(df)) + table.rows.extend(df.to_dict("records")) + + +def _fake_delete(table, where): + table.delete_calls.append(where) + repo = where.split("'")[1] + table.rows = [row for row in table.rows if row["metadata"].repo != repo] + + +def _fake_table(existing_rows=()): + fake_table = SimpleNamespace() + fake_table.rows = list(existing_rows) + fake_table.add_calls = [] + fake_table.delete_calls = [] + fake_table.add = lambda df: _fake_add(fake_table, df) + fake_table.delete = lambda where: _fake_delete(fake_table, where) + fake_table.search = lambda: _FakeSearch(fake_table) + return fake_table + + +class _FakeLanceQuery: + def __init__(self, table): + self._table = table + self._id_filter = None + + def limit(self, limit): + return self + + def where(self, expr, prefilter=False): + if expr.startswith("id="): + self._id_filter = expr[expr.index("=") + 1 :].strip("'\"") + return self + + def to_list(self): + if self._id_filter is None: + return list(self._table.rows) + return [row for row in self._table.rows if row["id"] == self._id_filter] + + +class _FakeSearchableTable: + def __init__(self, rows): + self.rows = rows + self.add_calls = [] + self.delete_calls = [] + + def __len__(self): + return len(self.rows) + + def search(self, query=None): + return _FakeLanceQuery(self) + + def add(self, df): + self.add_calls.append(len(df)) + self.rows.extend(df.to_dict()) + + def delete(self, where): + self.delete_calls.append(where) + if where.startswith("metadata.repo="): + repo = where[where.index("=") + 1 :].strip("'\"") + self.rows = [row for row in self.rows if row["metadata"]["repo"] != repo] + + +def _lancedb_row(record_id, repo): + return { + "id": record_id, + "text": "a body", + "metadata": {"repo": repo}, + "vector": [0.0] * 8, + } + + +def test_initial_creation_overwrites_the_table(monkeypatch): + """A from-scratch run (no existing table) creates the table with overwrite and does not sleep.""" + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_db.created_with = None + fake_db.create_table = lambda name, data, mode: fake_db.__setattr__( + "created_with", (name, mode) + ) + fake_db.table = None + + tool = _make_tool(monkeypatch, fake_db) + tool.table = fake_db.table + + monkeypatch.setattr( + time, "sleep", lambda seconds: pytest.fail(f"unexpected sleep({seconds}) after create") + ) + + tool._update_table_with_issues( + [_fake_issue()], + "utkarsh-demo", + ingest=False, + ) + + assert fake_db.created_with == ("codium-ai-pr-agent-issues", "overwrite") + + +def test_ingest_appends_rows_when_table_exists(monkeypatch): + """Add new rows to an existing table instead of dropping them, without sleeping.""" + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_table = _fake_table() + fake_db.table = fake_table + + tool = _make_tool(monkeypatch, fake_db) + + monkeypatch.setattr( + time, "sleep", lambda seconds: pytest.fail(f"unexpected sleep({seconds}) after add") + ) + + tool._update_table_with_issues( + [_fake_issue()], + "utkarsh-demo", + ingest=True, + ) + + assert fake_table.add_calls == [2] + assert fake_table.delete_calls == [] + + +def test_ingest_fetches_table_when_table_handle_unset(monkeypatch): + """A missing table handle is fetched from the db before appending rows.""" + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_table = _fake_table() + fake_db.table = fake_table + + tool = _make_tool(monkeypatch, fake_db) + tool.table = None + + tool._update_table_with_issues( + [_fake_issue()], + "utkarsh-demo", + ingest=True, + ) + + assert fake_table.add_calls == [2] + assert tool.table is fake_table + + +def test_force_refresh_replaces_only_the_current_repo_rows(monkeypatch): + """A forced refresh deletes only the current repo rows, then appends refreshed ones.""" + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_table = _fake_table() + fake_db.table = fake_table + + tool = _make_tool(monkeypatch, fake_db) + + tool._update_table_with_issues( + [_fake_issue()], + "org/repo-a", + ingest=True, + force_refresh=True, + ) + + assert fake_table.delete_calls == ["metadata.repo='org/repo-a'"] + assert fake_table.add_calls == [2] + + +def test_ingest_warns_when_table_missing(monkeypatch): + """Avoid adding rows when the table does not exist.""" + fake_db = FakeDB([]) + fake_table = _fake_table() + fake_db.table = fake_table + + tool = _make_tool(monkeypatch, fake_db) + + tool._update_table_with_issues( + [_fake_issue()], + "utkarsh-demo", + ingest=True, + ) + + assert fake_table.add_calls == [] + + +def test_ingest_finds_a_table_beyond_the_first_pagination_page(monkeypatch): + """A table past the first ten names is still found.""" + names = [f"table-{i:02d}" for i in range(13)] + names[10] = "codium-ai-pr-agent-issues" + fake_db = FakeDB(names) + fake_table = _fake_table() + fake_db.table = fake_table + + tool = _make_tool(monkeypatch, fake_db) + + tool._update_table_with_issues( + [_fake_issue()], + "utkarsh-demo", + ingest=True, + ) + + assert fake_table.add_calls == [2] + + +def test_init_does_not_rebuild_when_the_table_sorts_past_the_first_ten(monkeypatch): + """A table beyond the deprecated pagination cap is recognized, so the index is not rebuilt.""" + names = [f"table-{i:02d}" for i in range(13)] + names[10] = "codium-ai-pr-agent-issues" + + tool = _make_tool(monkeypatch, FakeDB(names)) + + assert tool._table_exists_in_db("codium-ai-pr-agent-issues") + + +def test_init_rebuilds_when_the_table_is_absent(monkeypatch): + """A genuinely missing table is still treated as needing a full rebuild.""" + tool = _make_tool(monkeypatch, FakeDB(["table-00"])) + + assert not tool._table_exists_in_db("codium-ai-pr-agent-issues") +def _capturing_table(): + fake_table = _fake_table() + fake_table.added_rows = [] + fake_table.add = lambda df: fake_table.added_rows.extend(df.to_dict(orient="records")) + return fake_table + + +_LONG_COMMENT = "this comment body has more than ten words and so it will be indexed in full" + + +def _fake_issue_with_comments(comments): + return SimpleNamespace( + number=5, + title="a title", + body="a body", + pull_request=False, + user=SimpleNamespace(login="tester"), + created_at="2026-01-01T00:00:00Z", + get_comments=lambda: comments, + ) + + +def _tool_with_comments(monkeypatch, fake_db): + tool = _make_tool(monkeypatch, fake_db) + tool._process_issue = lambda issue: ( + f"title: {issue.title}\nbody: {issue.body}", + list(issue.get_comments()), + issue.number, + ) + return tool + + +def test_ingest_skips_null_comment_body_without_crashing(monkeypatch): + """A comment with a null body is skipped instead of raising AttributeError.""" + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_table = _capturing_table() + fake_db.table = fake_table + + tool = _tool_with_comments(monkeypatch, fake_db) + issue = _fake_issue_with_comments([ + SimpleNamespace(body=_LONG_COMMENT), + SimpleNamespace(body=None), + ]) + + tool._update_table_with_issues([issue], "org/repo", ingest=True) + + texts = [row["text"] for row in fake_table.added_rows] + assert _LONG_COMMENT in texts + assert len(fake_table.added_rows) == 3 # sentinel + issue + one comment + + +def test_ingest_skips_short_comments_and_indexes_long_ones(monkeypatch): + """Word-count guard still skips short bodies while long ones are indexed.""" + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_table = _capturing_table() + fake_db.table = fake_table + + tool = _tool_with_comments(monkeypatch, fake_db) + issue = _fake_issue_with_comments([ + SimpleNamespace(body="short"), + SimpleNamespace(body=_LONG_COMMENT), + ]) + + tool._update_table_with_issues([issue], "org/repo", ingest=True) + + texts = [row["text"] for row in fake_table.added_rows] + assert "short" not in texts + assert _LONG_COMMENT in texts + assert len(fake_table.added_rows) == 3 # sentinel + issue + one comment + + +def test_second_repository_missing_sentinel_joins_instead_of_crashing(monkeypatch): + """Detect a repo never indexed on an existing table and join it without an IndexError.""" + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_db.table = _FakeSearchableTable([_lancedb_row("example_issue_org-repo-a", "org-repo-a")]) + tool = _make_tool(monkeypatch, fake_db) + + assert tool._lancedb_repo_already_indexed("org-repo-a") is True + assert tool._lancedb_repo_already_indexed("org-repo-b") is False + + tool._update_table_with_issues([_fake_issue()], "org-repo-b", ingest=True) + + assert tool._lancedb_repo_already_indexed("org-repo-b") is True + + +def test_each_repository_gets_exactly_one_sentinel_row(monkeypatch): + """Add the second repo's sentinel row without touching the first repo's rows.""" + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_db.table = _FakeSearchableTable([_lancedb_row("example_issue_org-repo-a", "org-repo-a")]) + tool = _make_tool(monkeypatch, fake_db) + + tool._update_table_with_issues([_fake_issue()], "org-repo-b", ingest=True) + + sentinels = [ + row["id"] for row in fake_db.table.rows if row["id"].startswith("example_issue_") + ] + assert sentinels == ["example_issue_org-repo-a", "example_issue_org-repo-b"] + assert fake_db.table.delete_calls == [] + + +class _LanceSettings: + class CONFIG: + CLI_MODE = True + + class config: + model = "gpt-4o-mini" + + class pr_similar_issue: + vectordb = "lancedb" + max_issues_to_scan = 10 + skip_comments = True + force_update_dataset = False + + class lancedb: + uri = "/tmp/test-lancedb" + + +class _FakeRepo: + def __init__(self): + self.full_name = "org/repo-b" + + def get_issues(self, state="all"): + return [_fake_issue(5), _fake_issue(6)] + + +class _FakeGithubClient: + def __init__(self): + self._repo = _FakeRepo() + + def get_repo(self, name): + return self._repo + + +class _FakeProvider: + def __init__(self): + self.repo_obj = _FakeRepo() + self.github_client = _FakeGithubClient() + + @staticmethod + def _parse_issue_url(url): + return ("org/repo-b", 5) + + +def _install_fake_lancedb(monkeypatch, fake_db): + fake_lancedb = types.ModuleType("lancedb") + fake_lancedb.connect = lambda uri: fake_db + monkeypatch.setitem(sys.modules, "lancedb", fake_lancedb) + + +def _stub_constructor_dependencies(monkeypatch, fake_db): + _install_fake_lancedb(monkeypatch, fake_db) + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_settings", lambda: _LanceSettings) + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_git_provider", lambda: _FakeProvider) + monkeypatch.setattr( + "pr_agent.tools.pr_similar_issue._provider_supports_issue_indexing", + lambda: True, + ) + monkeypatch.setattr("pr_agent.tools.pr_similar_issue._embed_with_fallback", _fake_embed) + monkeypatch.setattr( + "pr_agent.tools.pr_similar_issue.TokenHandler", + lambda *args, **kwargs: SimpleNamespace(count_tokens=lambda text: 0), + ) + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.time.sleep", lambda seconds: None) + + +def test_constructor_second_repo_joins_existing_table(monkeypatch): + """Construct the tool for a second repo on an existing table and construct it again.""" + _install_fake_pandas(monkeypatch) + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_db.table = _FakeSearchableTable([_lancedb_row("example_issue_org-repo-a", "org-repo-a")]) + _stub_constructor_dependencies(monkeypatch, fake_db) + + tool = PRSimilarIssue("https://github.com/org/repo-b/pull/5", None) + + assert tool.repo_name_for_index == "org-repo-b" + assert fake_db.table.delete_calls == ["metadata.repo='org-repo-b'"] # no-op first join + assert fake_db.table.add_calls == [3] # sentinel plus two issues + + PRSimilarIssue("https://github.com/org/repo-b/pull/5", None) + + assert fake_db.table.add_calls == [3] # sentinel is present now, incremental path only + sentinels = [ + row["id"] for row in fake_db.table.rows if row["id"].startswith("example_issue_") + ] + assert sentinels == ["example_issue_org-repo-a", "example_issue_org-repo-b"] + + +def test_concurrent_first_runs_do_not_duplicate_rows(monkeypatch): + """Overlapping first runs for the same repo leave a single set of rows.""" + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_db.table = _FakeSearchableTable([_lancedb_row("example_issue_org-repo-a", "org-repo-a")]) + tool = _make_tool(monkeypatch, fake_db) + + for _ in range(2): + tool._update_table_with_issues([_fake_issue()], "org-repo-b", ingest=True, force_refresh=True) + + ids = [row["id"] for row in fake_db.table.rows] + assert ids.count("example_issue_org-repo-b") == 1 + assert ids.count("issue_5.issue") == 1 + + +def test_sentinel_added_once_across_incremental_runs(monkeypatch): + """Later incremental runs add only the new issue row, not another sentinel. + + The incremental caller in run() already filters out indexed issues by id, so the method + receives only new issues; its own contract is limited to the de-duplication LanceDB cannot + do for the sentinel marker. + """ + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_table = _fake_table() + fake_db.table = fake_table + + tool = _make_tool(monkeypatch, fake_db) + + tool._update_table_with_issues([_fake_issue()], "utkarsh-demo", ingest=True) + tool._update_table_with_issues([_fake_issue(6)], "utkarsh-demo", ingest=True) + + assert fake_table.add_calls == [2, 1] + sentinels = [row for row in fake_table.rows if row["id"] == "example_issue_utkarsh-demo"] + assert len(sentinels) == 1 + assert len([row for row in fake_table.rows if row["id"] == "issue_5.issue"]) == 1 + + +def test_initial_creation_with_empty_corpus_still_builds_table(monkeypatch): + """A from-scratch run on an empty corpus still creates the table (sentinel row only).""" + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_db.created_with = None + fake_db.create_table = lambda name, data, mode: fake_db.__setattr__( + "created_with", (name, mode, data) + ) + fake_db.table = None + + tool = _make_tool(monkeypatch, fake_db) + tool.table = fake_db.table + + tool._update_table_with_issues([], "utkarsh-demo", ingest=False) + + name, mode, df = fake_db.created_with + assert name == "codium-ai-pr-agent-issues" + assert mode == "overwrite" + assert [row["id"] for row in df.to_dict("records")] == ["example_issue_utkarsh-demo"] + + +def test_ingest_empty_corpus_skips_update(monkeypatch): + """An empty corpus on the ingest path is skipped without touching the table.""" + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_table = _fake_table(existing_rows=[ + {"id": "issue_1.issue", "text": "an old issue", + "metadata": SimpleNamespace(repo="org/repo-a")}, + ]) + fake_db.table = fake_table + + tool = _make_tool(monkeypatch, fake_db) + + tool._update_table_with_issues([], "org/repo-a", ingest=True) + + assert fake_table.add_calls == [] + assert fake_table.delete_calls == [] + assert len(fake_table.rows) == 1 + + +def test_force_refresh_keeps_a_single_sentinel(monkeypatch): + """A forced refresh deletes the repo rows, then re-adds a single sentinel.""" + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_table = _fake_table(existing_rows=[ + {"id": "example_issue_org/repo-a", "text": "example_issue", + "metadata": SimpleNamespace(repo="org/repo-a")}, + ]) + fake_db.table = fake_table + + tool = _make_tool(monkeypatch, fake_db) + + tool._update_table_with_issues( + [_fake_issue()], + "org/repo-a", + ingest=True, + force_refresh=True, + ) + + assert fake_table.delete_calls == ["metadata.repo='org/repo-a'"] + assert fake_table.add_calls == [2] + sentinels = [row for row in fake_table.rows if row["id"] == "example_issue_org/repo-a"] + assert len(sentinels) == 1 + + +def test_force_refresh_empty_corpus_restores_the_sentinel(monkeypatch): + """A forced refresh with no embeddable issues clears stale rows and keeps the sentinel.""" + fake_db = FakeDB(["codium-ai-pr-agent-issues"]) + fake_table = _fake_table(existing_rows=[ + {"id": "issue_1.issue", "text": "an old issue", + "metadata": SimpleNamespace(repo="org/repo-a")}, + {"id": "example_issue_org/repo-a", "text": "example_issue", + "metadata": SimpleNamespace(repo="org/repo-a")}, + ]) + fake_db.table = fake_table + + tool = _make_tool(monkeypatch, fake_db) + + tool._update_table_with_issues([], "org/repo-a", ingest=True, force_refresh=True) + + assert fake_table.delete_calls == ["metadata.repo='org/repo-a'"] + assert fake_table.add_calls == [1] + sentinels = [row for row in fake_table.rows if row["id"] == "example_issue_org/repo-a"] + assert len(sentinels) == 1 + assert len(fake_table.rows) == 1 diff --git a/tests/unittest/test_similar_issue_lancedb_query.py b/tests/unittest/test_similar_issue_lancedb_query.py new file mode 100644 index 0000000000..e78570e213 --- /dev/null +++ b/tests/unittest/test_similar_issue_lancedb_query.py @@ -0,0 +1,47 @@ +"""The lancedb similar-issue query uses the same cosine metric and five-hit limit as the other backends.""" +from types import SimpleNamespace + +from pr_agent.tools.pr_similar_issue import _lancedb_similar_search + + +class _FakeBuilder: + def __init__(self, rows): + self._rows = rows + self.distance_type_value = None + self.limit_value = None + self.where_called_with = None + + def distance_type(self, value): + self.distance_type_value = value + return self + + def limit(self, value): + self.limit_value = value + return self + + def where(self, expr, prefilter=False): + self.where_called_with = (expr, prefilter) + return self + + def to_list(self): + return self._rows + + +def _fake_table(builder): + return SimpleNamespace(search=lambda query: builder) + + +def test_lancedb_query_requests_cosine_distance_and_five_hits(): + """The search reflects cosine similarity scores and matches the other backends' five-hit limit.""" + rows = [ + {"id": "1.issue", "_distance": 0.11, "text": "a"}, + {"id": "2.issue", "_distance": 0.30, "text": "b"}, + ] + builder = _FakeBuilder(rows) + + result = _lancedb_similar_search(_fake_table(builder), [0.0] * 8, "org/repo-a") + + assert builder.distance_type_value == "cosine" + assert builder.limit_value == 5 + assert builder.where_called_with == ("metadata.repo='org/repo-a'", True) + assert result == rows diff --git a/tests/unittest/test_similar_issue_non_github.py b/tests/unittest/test_similar_issue_non_github.py deleted file mode 100644 index 6581448692..0000000000 --- a/tests/unittest/test_similar_issue_non_github.py +++ /dev/null @@ -1,49 +0,0 @@ -import pytest - -from pr_agent.tools.pr_similar_issue import PRSimilarIssue - - -@pytest.mark.asyncio -async def test_similar_issue_non_github_publishes_message(monkeypatch): - class FakeProvider: - def __init__(self): - self.comments = [] - - def publish_comment(self, body): - self.comments.append(body) - - fake_provider = FakeProvider() - - class FakeSettings: - class config: - git_provider = "gitlab" - publish_output = True - - monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_settings", lambda: FakeSettings) - monkeypatch.setattr( - "pr_agent.git_providers.get_git_provider_with_context", - lambda _: fake_provider, - ) - - tool = PRSimilarIssue("https://gitlab.example.com/group/repo/-/merge_requests/1", None) - result = await tool.run() - - assert result == "" - assert fake_provider.comments == [ - "The /similar_issue tool is currently supported only for GitHub." - ] - - -@pytest.mark.asyncio -async def test_similar_issue_non_github_no_publish(monkeypatch): - class FakeSettings: - class config: - git_provider = "gitlab" - publish_output = False - - monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_settings", lambda: FakeSettings) - - tool = PRSimilarIssue("https://gitlab.example.com/group/repo/-/merge_requests/1", None) - result = await tool.run() - - assert result == "" diff --git a/tests/unittest/test_similar_issue_pinecone.py b/tests/unittest/test_similar_issue_pinecone.py new file mode 100644 index 0000000000..0998152317 --- /dev/null +++ b/tests/unittest/test_similar_issue_pinecone.py @@ -0,0 +1,457 @@ +"""Focused unit tests for the pinecone backend of the similar issue tool.""" +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import pr_agent.tools.pr_similar_issue as psi + + +class SettingsStub: + class CONFIG: + CLI_MODE = True + + class pr_similar_issue: + skip_comments = True + max_issues_to_scan = 100 + vectordb = "pinecone" + force_update_dataset = False + + class pinecone: + api_key = "pinecone-key" + cloud = "aws" + region = "us-east-1" + + +class _PandasSeries(list): + @property + def values(self): + return list(self) + + def to_list(self): + return list(self) + + +class _PandasDataFrame: + def __init__(self, records): + self._records = list(records) + self._overrides = {} + + def __getitem__(self, column): + if column in self._overrides: + return _PandasSeries(self._overrides[column]) + return _PandasSeries([row[column] for row in self._records]) + + def __setitem__(self, column, values): + self._overrides[column] = list(values) + + def to_dict(self, orient="records"): + rows = [dict(record) for record in self._records] + for column, values in self._overrides.items(): + for row, value in zip(rows, values, strict=True): + row[column] = value + return rows + + +def _make_issue(number): + return SimpleNamespace( + pull_request=False, + title=f"Issue {number}", + body="Body", + number=number, + user=SimpleNamespace(login="user"), + created_at="2020-01-01", + get_comments=lambda: [], + ) + + +def _make_tool(pc): + tool = psi.PRSimilarIssue.__new__(psi.PRSimilarIssue) + tool.pc = pc + tool.pc_spec = None + tool.index_name = "codium-ai-pr-agent-issues" + tool.max_issues_to_scan = 100 + tool.token_handler = SimpleNamespace(count_tokens=lambda text: 1) + tool.pinecone_index = None + return tool + + +def _stub_embeddings(monkeypatch): + monkeypatch.setitem(sys.modules, "pandas", + SimpleNamespace(DataFrame=_PandasDataFrame)) + embedding = [0.5, 0.5] + monkeypatch.setattr(psi, "get_settings", lambda: SettingsStub) + monkeypatch.setattr(psi, "get_max_tokens", lambda model: 8192) + monkeypatch.setattr(psi, "_embed_with_fallback", + lambda texts: [embedding for _ in texts]) + monkeypatch.setattr(psi.time, "sleep", lambda seconds: None) + + +def test_pinecone_indexes_exactly_max_issues(monkeypatch): + saved_vectors = [] + + class FakeIndex: + def upsert(self, **kwargs): + saved_vectors.extend(kwargs["vectors"]) + + tool = _make_tool(SimpleNamespace(Index=lambda name: FakeIndex())) + tool.max_issues_to_scan = 3 + _stub_embeddings(monkeypatch) + + issues = [_make_issue(number) for number in range(1, 6)] + tool._update_index_with_issues(issues, "example-repo", pinecone_namespace="ns", upsert=True) + + issue_ids = [ + vector[0] for vector in saved_vectors + if vector[0].endswith(".issue") + ] + assert issue_ids == ["issue_1.issue", "issue_2.issue", "issue_3.issue"] + + +def test_pinecone_namespace_does_not_collapse_repo_separators(): + assert psi._pinecone_namespace("foo/bar-baz") != psi._pinecone_namespace("foo-bar/baz") + + +def test_pinecone_upsert_passes_batch_size_and_max_concurrency(monkeypatch): + calls = {} + pinecone_namespace = psi._pinecone_namespace("Example/Repo") + + class FakeIndex: + def upsert(self, **kwargs): + calls.update(kwargs) + + tool = _make_tool(SimpleNamespace(Index=lambda name: FakeIndex())) + _stub_embeddings(monkeypatch) + + tool._update_index_with_issues( + [_make_issue(7)], + "example-repo", + pinecone_namespace=pinecone_namespace, + upsert=True, + ) + + assert calls["namespace"] == pinecone_namespace + assert calls["batch_size"] == 100 + assert calls["max_concurrency"] == 10 + assert [vector[0] for vector in calls["vectors"]] == [ + "example_issue_example-repo", + "issue_7.issue", + ] + + +def test_pinecone_existing_index_fetches_from_repo_namespace(monkeypatch): + fetches = [] + + class FakeIndex: + def fetch(self, **kwargs): + fetches.append(kwargs) + return SimpleNamespace(to_dict=lambda: {"vectors": { + kwargs["ids"][0]: {"metadata": {"repo": "example-repo"}}, + }}) + + class FakePineconeClient: + def __init__(self, api_key): + self.api_key = api_key + + def has_index(self, index_name): + return True + + def Index(self, name): + return FakeIndex() + + class FakeProvider: + @staticmethod + def supports_issue_indexing(): + return True + + def __init__(self): + self.github_client = SimpleNamespace( + get_repo=lambda repo_name: SimpleNamespace( + full_name="Example/Repo", + get_issues=lambda state: [_make_issue(7)], + ) + ) + + def _parse_issue_url(self, issue_url): + return "Example/Repo", 1 + + fake_pinecone_module = SimpleNamespace( + Pinecone=FakePineconeClient, + ServerlessSpec=lambda cloud, region: SimpleNamespace(cloud=cloud, region=region), + ) + + monkeypatch.setitem(sys.modules, "pinecone", fake_pinecone_module) + monkeypatch.setattr(psi, "get_settings", lambda: SettingsStub) + monkeypatch.setattr(psi, "get_git_provider", lambda: FakeProvider) + + tool = psi.PRSimilarIssue("https://github.com/Example/Repo/issues/1", ai_handler=None) + pinecone_namespace = psi._pinecone_namespace("Example/Repo") + + assert tool.repo_name_for_index == "example-repo" + assert tool.pinecone_namespace == pinecone_namespace + assert fetches == [ + { + "ids": ["example_issue_example-repo"], + "namespace": pinecone_namespace, + }, + { + "ids": ["issue_7.issue"], + "namespace": pinecone_namespace, + } + ] + + +def test_pinecone_upsert_path_skips_index_creation(monkeypatch): + created = [] + + class FakeIndex: + def upsert(self, **kwargs): + pass + + pc = SimpleNamespace( + Index=lambda name: FakeIndex(), + create_index=lambda **kwargs: created.append(kwargs), + ) + tool = _make_tool(pc) + _stub_embeddings(monkeypatch) + + tool._update_index_with_issues( + [_make_issue(7)], + "example-repo", + pinecone_namespace=psi._pinecone_namespace("Example/Repo"), + upsert=True, + ) + + assert created == [] + + +def test_pinecone_upsert_response_errors_raise_before_sleep(monkeypatch): + logged = [] + + class FakeIndex: + def upsert(self, **kwargs): + return SimpleNamespace(has_errors=True, failed_item_count=100) + + tool = _make_tool(SimpleNamespace(Index=lambda name: FakeIndex())) + _stub_embeddings(monkeypatch) + monkeypatch.setattr(psi.time, "sleep", lambda seconds: pytest.fail("should not sleep after upsert errors")) + monkeypatch.setattr( + psi, + "get_logger", + lambda: SimpleNamespace( + info=lambda *args, **kwargs: None, + error=lambda *args, **kwargs: logged.append(kwargs.get("artifact")), + ), + ) + + with pytest.raises(RuntimeError, match="100"): + tool._update_index_with_issues( + [_make_issue(7)], + "example-repo", + pinecone_namespace="ns", + upsert=True, + ) + + assert logged == [{"failed_item_count": 100, "errors": []}] + + +def test_pinecone_upsert_response_dict_errors_raise(monkeypatch): + class FakeIndex: + def upsert(self, **kwargs): + return {"has_errors": True, "failed_item_count": 5} + + tool = _make_tool(SimpleNamespace(Index=lambda name: FakeIndex())) + _stub_embeddings(monkeypatch) + + with pytest.raises(RuntimeError, match="5"): + tool._update_index_with_issues( + [_make_issue(7)], + "example-repo", + pinecone_namespace="ns", + upsert=True, + ) + + +def test_pinecone_upsert_failed_item_count_raises_without_has_errors(monkeypatch): + class FakeIndex: + def upsert(self, **kwargs): + return {"failed_item_count": 3} + + tool = _make_tool(SimpleNamespace(Index=lambda name: FakeIndex())) + _stub_embeddings(monkeypatch) + + with pytest.raises(RuntimeError, match="3"): + tool._update_index_with_issues( + [_make_issue(7)], + "example-repo", + pinecone_namespace="ns", + upsert=True, + ) + + +def test_pinecone_upsert_logs_batch_error_messages(monkeypatch): + logged = [] + + class FakeIndex: + def upsert(self, **kwargs): + return SimpleNamespace( + has_errors=True, + failed_item_count=2, + errors=[ + SimpleNamespace(error_message="namespace does not exist"), + {"error_message": "duplicate id"}, + ], + ) + + tool = _make_tool(SimpleNamespace(Index=lambda name: FakeIndex())) + _stub_embeddings(monkeypatch) + monkeypatch.setattr( + psi, + "get_logger", + lambda: SimpleNamespace( + info=lambda *args, **kwargs: None, + error=lambda *args, **kwargs: logged.append(kwargs.get("artifact")), + ), + ) + + with pytest.raises(RuntimeError, match="2"): + tool._update_index_with_issues( + [_make_issue(7)], + "example-repo", + pinecone_namespace="ns", + upsert=True, + ) + + assert logged == [ + { + "failed_item_count": 2, + "errors": ["namespace does not exist", "duplicate id"], + }, + ] + + +def test_pinecone_upsert_waits_until_lsn_is_reconciled(monkeypatch): + fetched = [] + sleeps = [] + + class FakeIndex: + def upsert(self, **kwargs): + return SimpleNamespace( + has_errors=False, + response_info=SimpleNamespace(lsn_committed=10), + ) + + def fetch(self, **kwargs): + fetched.append(kwargs) + lsn_reconciled = 5 if len(fetched) == 1 else 10 + return SimpleNamespace(response_info=SimpleNamespace( + is_reconciled=lambda target: lsn_reconciled >= target + )) + + tool = _make_tool(SimpleNamespace(Index=lambda name: FakeIndex())) + _stub_embeddings(monkeypatch) + monkeypatch.setattr(psi.time, "sleep", lambda seconds: sleeps.append(seconds)) + + tool._update_index_with_issues( + [_make_issue(7)], + "example-repo", + pinecone_namespace="ns", + upsert=True, + ) + + assert fetched == [ + {"ids": ["example_issue_example-repo"], "namespace": "ns"}, + {"ids": ["example_issue_example-repo"], "namespace": "ns"}, + ] + assert sleeps == [psi.PINECONE_UPSERT_READY_POLL_SECONDS] + + +def test_pinecone_upsert_wait_supports_dict_response_info(): + class FakeIndex: + def __init__(self): + self.fetch_calls = 0 + + def fetch(self, **kwargs): + self.fetch_calls += 1 + return {"response_info": {"lsn_reconciled": 20}} + + fake_index = FakeIndex() + + psi._wait_for_pinecone_upsert_readiness( + fake_index, + {"response_info": {"lsn_committed": 20}}, + namespace="", + vector_id="example_issue_example-repo", + ) + + assert fake_index.fetch_calls == 1 + + +def test_pinecone_upsert_wait_checks_deadline_before_fetch(monkeypatch): + state = {"t": 0.0} + fetched = [] + + class FakeIndex: + def fetch(self, **kwargs): + fetched.append(kwargs) + return {"response_info": {"lsn_reconciled": 1}} + + monkeypatch.setattr(psi.time, "monotonic", lambda: state["t"]) + monkeypatch.setattr( + psi.time, + "sleep", + lambda seconds: state.__setitem__( + "t", state["t"] + psi.PINECONE_UPSERT_READY_TIMEOUT_SECONDS + 1 + ), + ) + + with pytest.raises(TimeoutError, match="not query-ready"): + psi._wait_for_pinecone_upsert_readiness( + FakeIndex(), + {"response_info": {"lsn_committed": 10}}, + namespace="ns", + vector_id="example_issue_example-repo", + ) + + assert len(fetched) == 1 + + +def test_pinecone_create_index_path_builds_new_index_then_upserts(monkeypatch): + created = [] + upserted = [] + + class FakeIndex: + def upsert(self, **kwargs): + upserted.append(kwargs) + + pc = SimpleNamespace( + Index=lambda name: FakeIndex(), + create_index=lambda **kwargs: created.append(kwargs), + ) + tool = _make_tool(pc) + _stub_embeddings(monkeypatch) + + tool._update_index_with_issues( + [_make_issue(7)], + "example-repo", + pinecone_namespace=psi._pinecone_namespace("Example/Repo"), + upsert=False, + ) + + assert len(created) == 1 + kwargs = created[0] + assert kwargs["name"] == tool.index_name + assert kwargs["dimension"] == 2 + assert kwargs["metric"] == "cosine" + assert kwargs["timeout"] == 120 + assert upserted, "expected the upsert to run after index creation" + + +def test_vectordb_defaults_to_lancedb(): + config_path = Path(psi.__file__).resolve().parents[2] / "pr_agent" / "settings" / "configuration.toml" + content = config_path.read_text(encoding="utf-8") + section = content.split("[pr_similar_issue]")[1].split("[")[0] + + assert 'vectordb = "lancedb"' in section + assert 'vectordb = "pinecone"' not in section diff --git a/tests/unittest/test_similar_issue_provider_capability.py b/tests/unittest/test_similar_issue_provider_capability.py new file mode 100644 index 0000000000..db50b52aa1 --- /dev/null +++ b/tests/unittest/test_similar_issue_provider_capability.py @@ -0,0 +1,134 @@ +from types import SimpleNamespace + +import pytest + +from pr_agent.git_providers.git_provider import GitProvider +from pr_agent.git_providers.github_provider import GithubProvider +from pr_agent.tools.pr_similar_issue import PRSimilarIssue, _provider_supports_issue_indexing + + +class UnsupportedProvider(GitProvider): + """A provider class that does not declare the issue-indexing capability.""" + + def is_supported(self, capability: str) -> bool: + return False + + +@pytest.mark.asyncio +async def test_similar_issue_unsupported_provider_publishes_message(monkeypatch): + class FakeProvider: + def __init__(self): + self.comments = [] + + def publish_comment(self, body): + self.comments.append(body) + + fake_provider = FakeProvider() + + class FakeSettings: + class config: + git_provider = "gitlab" + publish_output = True + + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_settings", lambda: FakeSettings) + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_git_provider", lambda: UnsupportedProvider) + monkeypatch.setattr( + "pr_agent.git_providers.get_git_provider_with_context", + lambda _: fake_provider, + ) + + tool = PRSimilarIssue("https://gitlab.example.com/group/repo/-/merge_requests/1", None) + result = await tool.run() + + assert result == "" + assert fake_provider.comments == [ + "The /similar_issue tool is not supported by the configured git provider." + ] + + +@pytest.mark.asyncio +async def test_similar_issue_unsupported_provider_no_publish(monkeypatch): + class FakeSettings: + class config: + git_provider = "gitlab" + publish_output = False + + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_settings", lambda: FakeSettings) + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_git_provider", lambda: UnsupportedProvider) + + tool = PRSimilarIssue("https://gitlab.example.com/group/repo/-/merge_requests/1", None) + result = await tool.run() + + assert result == "" + + +def test_github_provider_declares_issue_indexing(): + """The capability replaces the previous `git_provider == "github"` string comparison.""" + assert GithubProvider.supports_issue_indexing() is True + + +def test_git_provider_default_is_unsupported(): + """A provider that does not override the capability is treated as unsupported.""" + assert GitProvider.supports_issue_indexing() is False + assert UnsupportedProvider.supports_issue_indexing() is False + + +def test_capability_is_read_off_the_registered_class(monkeypatch): + """A provider registered from outside the package is judged by what it declares.""" + + class ThirdPartyProvider(GitProvider): + def is_supported(self, capability: str) -> bool: + return False + + @classmethod + def supports_issue_indexing(cls) -> bool: + return True + + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_git_provider", lambda: ThirdPartyProvider) + assert _provider_supports_issue_indexing() is True + + +def test_declared_capability_beats_the_configured_provider_id(monkeypatch): + """A non-`github` id that declares the capability is supported. + + This is the behavior change: the previous `config.git_provider == "github"` comparison + turned any other id away regardless of what the provider could do. The provider is built and + interrupted at its GitHub client hook, so the test observes that the guard let it through + without running the indexing path. + """ + + class Sentinel(Exception): + pass + + class CapableProvider(GithubProvider): + def _get_github_client(self): + raise Sentinel + + @classmethod + def supports_issue_indexing(cls) -> bool: + return True + + class FakeSettings: + class config: + git_provider = "my-forge" + publish_output = False + CLI_MODE = True + + CONFIG = config + pr_similar_issue = SimpleNamespace(max_issues_to_scan=10, vectordb="pinecone") + + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_settings", lambda: FakeSettings) + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_git_provider", lambda: CapableProvider) + + with pytest.raises(Sentinel): + PRSimilarIssue("https://my-forge.example.com/group/repo/issues/1", None) + + +def test_unknown_provider_configuration_is_unsupported(monkeypatch): + """An unresolvable provider id is reported as unsupported rather than raised.""" + + def raise_unknown(): + raise ValueError("Unknown git provider: nope") + + monkeypatch.setattr("pr_agent.tools.pr_similar_issue.get_git_provider", raise_unknown) + assert _provider_supports_issue_indexing() is False diff --git a/tests/unittest/test_similar_issue_qdrant_collection.py b/tests/unittest/test_similar_issue_qdrant_collection.py index 8878a78f36..fce504d74c 100644 --- a/tests/unittest/test_similar_issue_qdrant_collection.py +++ b/tests/unittest/test_similar_issue_qdrant_collection.py @@ -1,11 +1,104 @@ """The qdrant collection name is suffixed so pre-#2323 points cannot surface in results.""" + import inspect +import sys +from types import SimpleNamespace import pr_agent.tools.pr_similar_issue as psi BASE_INDEX_NAME = "codium-ai-pr-agent-issues" +class _PandasSeries(list): + def to_list(self): + return list(self) + + +class _PandasDataFrame: + def __init__(self, records): + self._records = list(records) + self._overrides = {} + + def __getitem__(self, column): + if column in self._overrides: + return _PandasSeries(self._overrides[column]) + return _PandasSeries([row[column] for row in self._records]) + + def __setitem__(self, column, values): + self._overrides[column] = list(values) + + def to_dict(self, orient="records"): + rows = [dict(record) for record in self._records] + for column, values in self._overrides.items(): + for row, value in zip(rows, values, strict=True): + row[column] = value + return rows + + +class _PointStruct: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +def test_qdrant_upload_points_uses_batching(monkeypatch): + calls = {} + + class FakeQdrant: + def upload_points(self, **kwargs): + calls.update(kwargs) + + fake_qdrant_models = SimpleNamespace(PointStruct=_PointStruct) + fake_qdrant_client = SimpleNamespace(models=fake_qdrant_models) + + monkeypatch.setitem( + sys.modules, + "pandas", + SimpleNamespace(DataFrame=_PandasDataFrame), + ) + monkeypatch.setitem( + sys.modules, + "qdrant_client", + fake_qdrant_client, + ) + monkeypatch.setitem( + sys.modules, + "qdrant_client.models", + fake_qdrant_models, + ) + + tool = psi.PRSimilarIssue.__new__(psi.PRSimilarIssue) + tool.qdrant = FakeQdrant() + tool.qdrant_collection_name = "test-collection" + tool.max_issues_to_scan = 100 + tool.token_handler = SimpleNamespace(count_tokens=lambda text: 1) + + issue = SimpleNamespace( + pull_request=False, + user=SimpleNamespace(login="user"), + created_at="2020-01-01", + number=7, + get_comments=lambda: [], + ) + + monkeypatch.setattr( + psi.PRSimilarIssue, + "_process_issue", + lambda self, issue: ("Issue body", [], issue.number), + ) + monkeypatch.setattr( + psi, + "_embed_with_fallback", + lambda texts: [[0.5, 0.5] for _ in texts], + ) + + tool._update_qdrant_with_issues([issue], "example-repo") + + assert calls["collection_name"] == "test-collection" + assert calls["batch_size"] == 100 + assert calls["wait"] is True + assert len(calls["points"]) == 2 + + def test_collection_name_has_the_v2_suffix(): """The v2 suffix keeps the new index separate from the pre-#2323 collection.""" assert psi._qdrant_collection_name(BASE_INDEX_NAME) == "codium-ai-pr-agent-issues-v2" @@ -16,23 +109,32 @@ def test_suffix_is_scoped_to_qdrant_only(): source = inspect.getsource(psi) qdrant_only_call_sites = [ "if not self.qdrant.collection_exists(collection_name=self.qdrant_collection_name):", - "self.qdrant.upsert(collection_name=self.qdrant_collection_name, points=points)", + "self.qdrant.upload_points(", + "batch_size=100", + "wait=True", ] for call_site in qdrant_only_call_sites: assert call_site in source assert 'index_name = self.index_name = "codium-ai-pr-agent-issues"' in source - assert "pinecone.Index(index_name=self.index_name)" in source + assert "self.pc.Index(name=self.index_name)" in source assert "self.db.create_table(self.index_name, data=df, mode=\"overwrite\")" in source - assert "self.qdrant_collection_name" not in source.split("elif get_settings().pr_similar_issue.vectordb == \"qdrant\":")[0] + assert "self.qdrant_collection_name" not in source.split( + "elif get_settings().pr_similar_issue.vectordb == \"qdrant\":" + )[0] def test_docs_carry_the_upgrade_note(): """The issue requires the upgrade note to ship with the similar_issue docs.""" from pathlib import Path - doc = Path(psi.__file__).resolve().parents[2] / "docs" / "docs" / "tools" / "similar_issues.md" + doc = ( + Path(psi.__file__).resolve().parents[2] + / "docs" + / "docs" + / "tools" + / "similar_issues.md" + ) content = doc.read_text(encoding="utf-8") - assert "codium-ai-pr-agent-issues-v2" in content diff --git a/tests/unittest/test_similar_issue_qdrant_credentials.py b/tests/unittest/test_similar_issue_qdrant_credentials.py new file mode 100644 index 0000000000..65c3431bd3 --- /dev/null +++ b/tests/unittest/test_similar_issue_qdrant_credentials.py @@ -0,0 +1,99 @@ +"""The qdrant credentials guard treats a blank url as unset instead of targeting localhost.""" +import sys +import types +from types import SimpleNamespace + +import pytest + +import pr_agent.tools.pr_similar_issue as psi + +CREDENTIALS_MESSAGE = "Please set qdrant url and api key in secrets file" + + +class _Recorder: + def __init__(self): + self.posted = [] + + def create_comment(self, body): + self.posted.append(body) + + +class _Repo: + full_name = "org/repo" + + def __init__(self): + self.recorder = _Recorder() + + def get_issue(self, number): + return self.recorder + + +class _Settings: + def __init__(self, cli_mode, url, api_key="secret"): + self.CONFIG = SimpleNamespace(CLI_MODE=cli_mode) + self.pr_similar_issue = SimpleNamespace( + max_issues_to_scan=10, skip_comments=True, force_update_dataset=False, vectordb="qdrant" + ) + kwargs = {"api_key": api_key} + if url is not None: + kwargs["url"] = url + self.qdrant = SimpleNamespace(**kwargs) + + +def _install_fakes(monkeypatch, settings, repo): + fake_client_module = types.ModuleType("qdrant_client") + fake_client_module.QdrantClient = SimpleNamespace + fake_models = types.ModuleType("qdrant_client.models") + for name in ("Distance", "FieldCondition", "Filter", "MatchValue", "VectorParams"): + setattr(fake_models, name, SimpleNamespace) + fake_client_module.models = fake_models + monkeypatch.setitem(sys.modules, "qdrant_client", fake_client_module) + monkeypatch.setitem(sys.modules, "qdrant_client.models", fake_models) + + class _FakeProvider: + @classmethod + def supports_issue_indexing(cls): + return True + + def __init__(self): + self.github_client = SimpleNamespace(get_repo=lambda name: repo) + self._parse_issue_url = lambda url: ("org/repo", "42") + + monkeypatch.setattr(psi, "get_git_provider", lambda: _FakeProvider) + monkeypatch.setattr(psi, "get_settings", lambda: settings) + monkeypatch.setattr( + psi, "TokenHandler", lambda *a, **k: SimpleNamespace(count_tokens=lambda _: 0) + ) + + +def _build_tool(monkeypatch, settings, repo=None): + repo = repo or _Repo() + _install_fakes(monkeypatch, settings, repo) + return repo, psi.PRSimilarIssue("https://github.com/org/repo/issues/42", ai_handler=None) + + +def test_blank_qdrant_url_is_treated_as_unset(monkeypatch): + """A blank url from the secrets template raises instead of targeting localhost.""" + with pytest.raises(Exception, match=CREDENTIALS_MESSAGE): + _build_tool(monkeypatch, _Settings(cli_mode=True, url="")) + + +def test_blank_url_and_blank_api_key_raise(monkeypatch): + """The secrets template ships both values blank; url alone decides, so this raises too.""" + with pytest.raises(Exception, match=CREDENTIALS_MESSAGE): + _build_tool(monkeypatch, _Settings(cli_mode=True, url="", api_key="")) + + +def test_missing_qdrant_url_still_raises(monkeypatch): + """An absent url keeps the pre-existing behaviour of raising the guidance error.""" + with pytest.raises(Exception, match=CREDENTIALS_MESSAGE): + _build_tool(monkeypatch, _Settings(cli_mode=True, url=None)) + + +def test_blank_url_in_app_mode_posts_the_guidance_comment(monkeypatch): + """App mode posts the guidance comment on the issue before raising.""" + repo = _Repo() + with pytest.raises(Exception, match=CREDENTIALS_MESSAGE): + _build_tool(monkeypatch, _Settings(cli_mode=False, url=""), repo=repo) + + assert repo.recorder.posted == [CREDENTIALS_MESSAGE] diff --git a/tests/unittest/test_special_token_literals.py b/tests/unittest/test_special_token_literals.py new file mode 100644 index 0000000000..156b3bfb93 --- /dev/null +++ b/tests/unittest/test_special_token_literals.py @@ -0,0 +1,78 @@ +from unittest.mock import Mock + +import pytest + +from pr_agent.algo.skills_loader import Skill, format_skills_context +from pr_agent.algo.token_budget import clip_tokens +from pr_agent.algo.token_handler import TokenEncoder, TokenHandler + + +@pytest.fixture +def encoder(monkeypatch): + def encode(text, *, disallowed_special="all"): + if disallowed_special != () and "<|endoftext|>" in text: + raise ValueError("Encountered text corresponding to disallowed special token") + return list(text) + + encoder = Mock() + encoder.encode.side_effect = encode + monkeypatch.setattr(TokenEncoder, "get_token_encoder", lambda model=None: encoder) + return encoder + + +@pytest.mark.parametrize("max_tokens", [-1, 0, 60, 10_000]) +@pytest.mark.parametrize("delete_last_line", [False, True]) +def test_special_token_literals_do_not_bypass_clipping(encoder, max_tokens, delete_last_line): + text = "line containing <|endoftext|>\n" * 20 + expected = clip_tokens( + text, max_tokens, num_input_tokens=len(text), delete_last_line=delete_last_line, + ) + + assert clip_tokens(text, max_tokens, delete_last_line=delete_last_line) == expected + encoder.encode.assert_called_once_with(text, disallowed_special=()) + + +def test_special_token_clipping_preserves_no_marker_option(encoder): + text = "<|endoftext|> " * 100 + + assert clip_tokens(text, 20, add_three_dots=False) == text[:18] + encoder.encode.assert_called_once_with(text, disallowed_special=()) + + +@pytest.mark.parametrize("text", [None, "", "ordinary text", "<|endoftext|>"]) +def test_empty_or_precounted_text_does_not_call_encoder(encoder, text): + assert clip_tokens(text, 100, num_input_tokens=len(text or "")) == text + encoder.encode.assert_not_called() + + +@pytest.mark.parametrize( + ("system", "user"), + [ + ("system <|endoftext|>", "user"), + ("system", "user <|endoftext|>"), + ("system {{ text }}", "user {{ text }}"), + ("system", "user"), + ], +) +def test_initial_prompt_count_treats_special_token_literals_as_text(encoder, system, user): + text = "<|endoftext|>" + handler = TokenHandler(object(), {"text": text}, system, user, model="test-model") + rendered = [template.replace("{{ text }}", text) for template in (system, user)] + + assert handler.prompt_tokens == sum(map(len, rendered)) + assert encoder.encode.call_count == 2 + for call, prompt in zip(encoder.encode.call_args_list, rendered, strict=True): + assert call.args == (prompt,) + assert call.kwargs == {"disallowed_special": ()} + + +def test_skills_context_treats_special_token_literals_as_text(): + skill = Skill( + name="tokenizer-guidance", + description="Use when reviewing tokenizer code.", + body="Document the literal <|endoftext|> token spelling.", + ) + + context = format_skills_context([skill], max_tokens=1_000) + + assert "<|endoftext|>" in context diff --git a/tests/unittest/test_suggestion_chunk_recovery.py b/tests/unittest/test_suggestion_chunk_recovery.py new file mode 100644 index 0000000000..513ffbebe2 --- /dev/null +++ b/tests/unittest/test_suggestion_chunk_recovery.py @@ -0,0 +1,567 @@ +"""Behavior of recovery after a partially successful suggestion pass.""" + +import asyncio +import copy +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +import yaml +from starlette_context import request_cycle_context + +import pr_agent.tools.pr_code_suggestions as module +from pr_agent.algo import token_budget as token_budget_module +from pr_agent.algo.pr_processing import retry_with_fallback_models +from pr_agent.algo.run_details import get_run_details, init_run_details +from pr_agent.algo.types import FilePatchInfo +from pr_agent.config_loader import get_settings +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + +PRCodeSuggestions = module.PRCodeSuggestions + + +@pytest.fixture +def configured(): + values = { + "config.model": "gpt-4o", + "config.fallback_models": ["gpt-4o-mini", "gpt-4.1"], + "config.publish_output": True, + "openai.deployment_id": "primary", + "openai.fallback_deployments": ["secondary", "last"], + "pr_code_suggestions.decouple_hunks": True, + "pr_code_suggestions.parallel_calls": True, + "pr_code_suggestions.suggestions_score_threshold": 0, + "pr_code_suggestions.max_suggestions_per_file": 0, + # Not used by most tests here, but snapshotted so the routed-primary test's + # writes are restored and no routing state leaks into later tests. + "model_routing.enable": False, + "model_routing.rules": [], + } + snapshot = snapshot_settings(tuple(values)) + for key, value in values.items(): + get_settings().set(key, value) + yield + restore_settings(snapshot) + + +def make_tool(monkeypatch, failures): + tool = PRCodeSuggestions.__new__(PRCodeSuggestions) + tool.git_provider = MagicMock() + tool.token_handler = MagicMock() + tool.vars = {"instructions": "scope-one"} + tool.pr_code_suggestions_prompt_system = "Review {{ instructions }}" + tool.pr_code_suggestions_prompt_user = "{{ diff }}" + calls = [] + + async def completion(*, model, system, user, **kwargs): + calls.append((model, user, get_settings().get("openai.deployment_id"), system)) + failure = failures.get((model, user)) + if isinstance(failure, BaseException): + raise failure + if callable(failure): + await failure() + if isinstance(failure, str): + return failure, "stop" + suggestion = { + "relevant_file": user + ".py", "one_sentence_summary": "Check " + user, + "label": "possible issue", "suggestion_content": "Handle the error.", + "existing_code": "old()", "improved_code": "new()", + "relevant_lines_start": 1, "relevant_lines_end": 1, + } + return yaml.safe_dump({"code_suggestions": [suggestion]}), "stop" + + async def reflect(*args): + return "" + + tool.ai_handler = SimpleNamespace(chat_completion=completion) + tool._self_reflect_with_fallback = reflect + monkeypatch.setattr(module, "get_pr_multi_diffs", lambda *a, **k: ["a", "b", "c"]) + return tool, calls + + +@pytest.mark.parametrize("parallel", [True, False]) +async def test_recovery_restores_missing_results_without_replacing_successes(configured, monkeypatch, parallel): + get_settings().set("pr_code_suggestions.parallel_calls", parallel) + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): TimeoutError("provider timeout")}) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert [(m, chunk, dep) for m, chunk, dep, _ in calls] == [ + ("gpt-4o", "a", "primary"), ("gpt-4o", "b", "primary"), + ("gpt-4o", "c", "primary"), ("gpt-4o-mini", "b", "secondary"), + ] + assert tool.failed_chunk_count == 0 + assert tool.total_chunk_count == 3 + assert get_settings().get("openai.deployment_id") == "primary" + + +async def test_recovery_exhaustion_keeps_successes_and_reports_remaining_gap(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, { + ("gpt-4o", "b"): RuntimeError("primary failed"), + ("gpt-4o", "c"): RuntimeError("primary failed"), + ("gpt-4o-mini", "b"): RuntimeError("secondary failed"), + ("gpt-4o-mini", "c"): RuntimeError("secondary failed"), + ("gpt-4.1", "c"): RuntimeError("last failed"), + }) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py"] + assert tool.failed_chunk_count == 1 + assert [(m, c) for m, c, _, _ in calls].count(("gpt-4o", "a")) == 1 + assert [c for m, c, _, _ in calls if m == "gpt-4.1"] == ["b", "c"] + + +async def test_healthy_run_never_enters_recovery(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, {}) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert len(result["code_suggestions"]) == 3 + assert tool.failed_chunk_count == 0 + assert {m for m, _, _, _ in calls} == {"gpt-4o"} + + +@pytest.mark.parametrize("fallbacks, expected", [([], ["a.py", "c.py"]), (["gpt-4o-mini"], ["a.py", "b.py", "c.py"])]) +async def test_fallback_recovery_runs_whenever_fallbacks_are_set(configured, monkeypatch, fallbacks, expected): + # Recovery is the default behaviour whenever config.fallback_models is set, + # so an empty fallback list preserves the partial-success policy while a + # configured fallback repairs the failed chunk. + get_settings().set("config.fallback_models", fallbacks) + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == expected + assert tool.failed_chunk_count == (1 if not fallbacks else 0) + assert len(calls) == (3 if not fallbacks else 4) + + +@pytest.mark.asyncio +async def test_larger_fallback_recovers_when_earlier_one_is_over_budget(configured, monkeypatch): + # The first fallback model cannot fit the full prompt, so it is skipped and + # recovery still moves on to the later model instead of giving up. + get_settings().set("config.fallback_models", ["gpt-4o-mini", "gpt-4.1"]) + monkeypatch.setattr( + token_budget_module, + "get_max_tokens", + lambda model, **kwargs: 1600 if model == "gpt-4o-mini" else 10000, + ) + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + tool.vars["instructions"] = "must retain these instructions " * 500 + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert tool.failed_chunk_count == 0 + assert [(m, c) for m, c, _, _ in calls if m == "gpt-4.1"] == [("gpt-4.1", "b")] + + +@pytest.mark.asyncio +async def test_recovery_skips_fallback_that_fits_only_a_clipped_chunk(configured, monkeypatch): + tool, _calls = make_tool(monkeypatch, {}) + tool._recovery_chain = MagicMock( + return_value=( + [ + ("gpt-4o", "primary"), + ("gpt-4o-mini", "secondary"), + ("gpt-4.1", "last"), + ], + 1, + ) + ) + recovered = {"code_suggestions": []} + tool._predict_chunks = AsyncMock(return_value=[recovered]) + + class FakeBudget: + def __init__(self, model): + self.model = model + + def render_prompt_templates(self, _variables): + return "system", "user" + + def fit_optional_text(self, optional_text, *_args, **_kwargs): + fitted_text = "clipped" if self.model == "gpt-4o-mini" else optional_text + return SimpleNamespace(optional_text=fitted_text) + + monkeypatch.setattr( + module.AttemptTokenBudget, + "for_prompt_attempt", + lambda model, *_args, **_kwargs: FakeBudget(model), + ) + results = [{"code_suggestions": []}, RuntimeError("primary failed")] + chunk_pairs = [("numbered-a", "a"), ("numbered-b", "complete-b")] + + await tool._recover_failed_chunks("gpt-4o", chunk_pairs, results) + + tool._predict_chunks.assert_awaited_once_with( + "gpt-4.1", + [("numbered-b", "complete-b")], + ) + assert results[1] is recovered + + +async def test_all_failed_primary_keeps_existing_outer_fallback(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, {("gpt-4o", c): RuntimeError("failure") for c in "abc"}) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert [m for m, _, _, _ in calls] == ["gpt-4o"] * 3 + ["gpt-4o-mini"] * 3 + assert tool.failed_chunk_count == 0 + + +async def test_empty_primary_chunk_list_keeps_outer_fallback(configured, monkeypatch): + get_settings().set("config.fallback_models", ["gpt-4.1"]) + tool, calls = make_tool(monkeypatch, {}) + packed_models = [] + + def pack_for_model(_provider, _token_handler, model, **_kwargs): + packed_models.append(model) + return [] if model == "gpt-4o" else ["a"] + + monkeypatch.setattr(module, "get_pr_multi_diffs", pack_for_model) + + result = await retry_with_fallback_models(tool.prepare_prediction_main) + + assert packed_models == ["gpt-4o", "gpt-4.1"] + assert [(model, chunk) for model, chunk, _, _ in calls] == [("gpt-4.1", "a")] + assert [suggestion["relevant_file"] for suggestion in result["code_suggestions"]] == ["a.py"] + + +async def test_partial_success_on_outer_fallback_only_tries_later_models(configured, monkeypatch): + failures = {("gpt-4o", c): RuntimeError("failure") for c in "abc"} + failures[("gpt-4o-mini", "b")] = RuntimeError("secondary failed") + tool, calls = make_tool(monkeypatch, failures) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert [m for m, _, _, _ in calls] == ["gpt-4o"] * 3 + ["gpt-4o-mini"] * 3 + ["gpt-4.1"] + assert get_settings().get("openai.deployment_id") == "primary" + + +@pytest.mark.parametrize("remaining_model", [True, False]) +@pytest.mark.asyncio +async def test_oversized_fallback_is_skipped_without_truncating_context(configured, monkeypatch, remaining_model): + if not remaining_model: + get_settings().set("config.fallback_models", ["gpt-4o-mini"]) + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + tool.vars["instructions"] = "must retain these instructions " * 500 + # Use the real per-model tokenizer, with a deliberately smaller configured capacity. + monkeypatch.setattr( + token_budget_module, + "get_max_tokens", + lambda model, **kwargs: 1600 if model == "gpt-4o-mini" else 10000, + ) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert not any(m == "gpt-4o-mini" for m, _, _, _ in calls) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ( + ["a.py", "b.py", "c.py"] if remaining_model else ["a.py", "c.py"] + ) + assert tool.failed_chunk_count == (0 if remaining_model else 1) + assert all(system == "Review " + tool.vars["instructions"] for _, _, _, system in calls) + + +async def test_marker_text_in_diff_is_counted_literally_for_recovery(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, {("gpt-4o", "<|endoftext|>"): RuntimeError("failure")}) + # Patch after make_tool so the fixture's default chunk list does not win. + monkeypatch.setattr(module, "get_pr_multi_diffs", lambda *a, **k: ["a", "<|endoftext|>", "c"]) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert ("gpt-4o-mini", "<|endoftext|>", "secondary") in [(m, c, d) for m, c, d, _ in calls] + assert len(result["code_suggestions"]) == 3 + + +@pytest.mark.asyncio +async def test_boundary_size_chunk_is_eligible_for_recovery(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + monkeypatch.setattr(module.TokenHandler, "count_tokens", lambda self, s: 1) + monkeypatch.setattr( + token_budget_module, + "get_max_tokens", + lambda model, **kwargs: 1050 if model == "gpt-4o-mini" else 10000, + ) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert tool.failed_chunk_count == 0 + + +async def test_recovery_reserves_handler_output_budget_for_fallback(configured, monkeypatch): + # When the AI handler exposes a concrete output allowance (e.g. a large + # config.max_output_tokens), recovery reserves it for the completion instead of + # the fixed soft threshold. The first fallback then cannot fit the prompt + # (5001 - 5000 = 1 < 50 tokens including framing), while the fixed 1500 threshold would have let + # it recover the chunk; only the later model recovers it. + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + reserves = [] + + def get_output_token_reserve(model, default): + reserves.append((model, get_settings().get("openai.deployment_id"))) + return 5000 + + tool.ai_handler = SimpleNamespace( + chat_completion=tool.ai_handler.chat_completion, + get_output_token_reserve=get_output_token_reserve, + ) + monkeypatch.setattr(module.TokenHandler, "count_tokens", lambda self, s: 1) + monkeypatch.setattr( + token_budget_module, + "get_max_tokens", + lambda model, **kwargs: 5001 if model == "gpt-4o-mini" else 10000, + ) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert tool.failed_chunk_count == 0 + assert [m for m, _, _, _ in calls] == ["gpt-4o"] * 3 + ["gpt-4.1"] + assert [(m, c) for m, c, _, _ in calls if m == "gpt-4.1"] == [("gpt-4.1", "b")] + fallback_reserves = [(model, deployment) for model, deployment in reserves if model != "gpt-4o"] + assert fallback_reserves == [ + ("gpt-4o-mini", "secondary"), + ("gpt-4.1", "last"), + ("gpt-4.1", "last"), + ] + + +async def test_empty_prediction_is_a_success_not_a_retry_trigger(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, { + ("gpt-4o", "a"): "code_suggestions: []", + ("gpt-4o", "b"): TimeoutError("failure"), + ("gpt-4o-mini", "b"): "code_suggestions: []", + }) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["c.py"] + assert tool.failed_chunk_count == 0 + assert len(calls) == 4 + + +async def test_invalid_yaml_recovery_preserves_parse_failure_notice(configured, monkeypatch): + tool, calls = make_tool(monkeypatch, { + ("gpt-4o", "b"): TimeoutError("failure"), + ("gpt-4o-mini", "b"): "not a suggestions mapping", + }) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "c.py"] + assert tool.failed_chunk_count == 1 + assert tool.parse_failure_count == 1 + assert len(calls) == 4 + + +@pytest.mark.parametrize("parent_cancel", [True, False]) +async def test_cancellation_stops_recovery_and_restores_deployment(configured, monkeypatch, parent_cancel): + started = asyncio.Event() + finished = asyncio.Event() + + async def wait_or_cancel(): + started.set() + try: + if parent_cancel: + await asyncio.Event().wait() + raise asyncio.CancelledError() + finally: + finished.set() + + tool, calls = make_tool(monkeypatch, { + ("gpt-4o", "b"): RuntimeError("failure"), + ("gpt-4o-mini", "b"): wait_or_cancel, + }) + task = asyncio.create_task(retry_with_fallback_models(tool.prepare_prediction_main)) + await asyncio.wait_for(started.wait(), timeout=5) + if parent_cancel: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert finished.is_set() + assert not any(m == "gpt-4.1" for m, _, _, _ in calls) + assert get_settings().get("openai.deployment_id") == "primary" + tool.git_provider.publish_comment.assert_not_called() + + +async def test_model_switch_waits_for_successful_sibling_to_finish(configured, monkeypatch): + completed = asyncio.Event() + + async def finish_later(): + await asyncio.sleep(0) + assert get_settings().get("openai.deployment_id") == "primary" + completed.set() + + async def recover(): + assert completed.is_set() + assert get_settings().get("openai.deployment_id") == "secondary" + + tool, _ = make_tool(monkeypatch, { + ("gpt-4o", "a"): finish_later, + ("gpt-4o", "b"): RuntimeError("failure"), + ("gpt-4o-mini", "b"): recover, + }) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert len(result["code_suggestions"]) == 3 + + +async def test_instances_do_not_reuse_predictions_or_prompt_context(configured, monkeypatch): + first, first_calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + await retry_with_fallback_models(first.prepare_prediction_main) + second, second_calls = make_tool(monkeypatch, {("gpt-4o", "a"): RuntimeError("failure")}) + second.vars["instructions"] = "scope-two" + result = await retry_with_fallback_models(second.prepare_prediction_main) + assert len(result["code_suggestions"]) == 3 + assert all(system == "Review scope-one" for _, _, _, system in first_calls) + assert all(system == "Review scope-two" for _, _, _, system in second_calls) + assert [c for m, c, _, _ in second_calls if m == "gpt-4o-mini"] == ["a"] + + +async def test_concurrent_requests_keep_separate_deployments(configured, monkeypatch): + both_recovering = asyncio.Event() + started = [] + + async def invoke(name): + with request_cycle_context({"settings": copy.deepcopy(get_settings())}): + get_settings().set("openai.deployment_id", name + "-primary") + get_settings().set("openai.fallback_deployments", [name + "-secondary", name + "-last"]) + + async def overlap(): + started.append(name) + if len(started) == 2: + both_recovering.set() + await asyncio.wait_for(both_recovering.wait(), timeout=5) + assert get_settings().get("openai.deployment_id") == name + "-secondary" + + tool, calls = make_tool(monkeypatch, { + ("gpt-4o", "b"): TimeoutError("failure"), ("gpt-4o-mini", "b"): overlap, + }) + tool.vars["instructions"] = name + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert len(result["code_suggestions"]) == 3 + assert all(dep.startswith(name) and system == "Review " + name for _, _, dep, system in calls) + assert get_settings().get("openai.deployment_id") == name + "-primary" + + await asyncio.gather(invoke("one"), invoke("two")) + assert get_settings().get("openai.deployment_id") == "primary" + + +@pytest.mark.parametrize("recovered", [True, False]) +async def test_published_suggestions_and_coverage_match_completed_chunks(configured, monkeypatch, recovered): + failures = {("gpt-4o", "b"): RuntimeError("primary failed")} + if not recovered: + failures.update({(model, "b"): RuntimeError("failed") for model in ["gpt-4o-mini", "gpt-4.1"]}) + tool, _ = make_tool(monkeypatch, failures) + tool.git_provider.diff_files = [FilePatchInfo("old()\n", "old()\n", "", c + ".py") for c in "abc"] + tool.git_provider.publish_code_suggestions.return_value = True + tool.git_provider.supports_code_suggestions_artifact.return_value = False + init_run_details() + result = await retry_with_fallback_models(tool.prepare_prediction_main) + await tool.push_inline_code_suggestions(result) + published = tool.git_provider.publish_code_suggestions.call_args.args[0] + assert [s["relevant_file"] for s in published] == (["a.py", "b.py", "c.py"] if recovered else ["a.py", "c.py"]) + assert all("```suggestion\nnew()\n```" in s["body"] for s in published) + assert bool(tool._get_suggestions_coverage_footer()) is not recovered + assert tool.git_provider.publish_comment.called is not recovered + details = get_run_details() + assert details.fallback_used is recovered + # The outer wrapper records the primary model after recovery restores the deployment, + # so the model line names the primary while the sticky flag preserves the fallback. + assert details.model_used == "gpt-4o" + assert details.num_ai_calls == 0 + + +async def test_recovered_suggestion_still_passes_anchor_validation(configured, monkeypatch): + tool, _ = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + tool.git_provider.diff_files = [FilePatchInfo("", "old()\n", "", c + ".py") for c in "ac"] + tool.git_provider.supports_code_suggestions_artifact.return_value = False + result = await retry_with_fallback_models(tool.prepare_prediction_main) + await tool.push_inline_code_suggestions(result) + published = tool.git_provider.publish_code_suggestions.call_args.args[0] + assert [s["relevant_file"] for s in published] == ["a.py", "c.py"] + assert "b.py" in tool.git_provider.publish_comment.call_args.args[0] + assert "```suggestion" not in tool.git_provider.publish_comment.call_args.args[0] + + +async def test_recovered_run_details_report_primary_model_and_sticky_fallback(configured, monkeypatch): + tool, _ = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + tool.git_provider.diff_files = [FilePatchInfo("old()\n", "old()\n", "", c + ".py") for c in "abc"] + tool.git_provider.publish_code_suggestions.return_value = True + tool.git_provider.supports_code_suggestions_artifact.return_value = False + init_run_details() + result = await retry_with_fallback_models(tool.prepare_prediction_main) + await tool.push_inline_code_suggestions(result) + details = get_run_details() + assert details.fallback_used is True + assert details.model_used == "gpt-4o" + assert details.num_ai_calls == 0 + + +async def test_ambiguous_model_deployment_chain_keeps_partial_result(configured, monkeypatch): + get_settings().set("config.fallback_models", ["gpt-4o", "gpt-4.1"]) + get_settings().set("openai.fallback_deployments", ["primary", "last"]) + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "c.py"] + assert len(calls) == 3 + + +async def test_repeated_fallback_pair_skips_recoveries_without_retrying_it(configured, monkeypatch): + # A unique fallback pair appearing twice in the chain would route the same + # failed chunk to an identical (model, deployment) a second time, so recovery + # gives up and keeps the partial result instead of adding duplicate inference. + get_settings().set("config.fallback_models", ["gpt-4o-mini", "gpt-4o-mini"]) + get_settings().set("openai.fallback_deployments", ["secondary", "secondary"]) + tool, calls = make_tool(monkeypatch, {("gpt-4o", "b"): RuntimeError("failure")}) + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "c.py"] + assert len(calls) == 3 + assert tool.failed_chunk_count == 1 + + +async def test_recovery_uses_existing_reflection_before_publishing_results(configured, monkeypatch): + keys = ("config.model_reasoning", "pr_code_suggestions_reflect_prompt.system", + "pr_code_suggestions_reflect_prompt.user") + snapshot = snapshot_settings(keys) + try: + get_settings().set(keys[0], "gpt-4o") + get_settings().set(keys[1], "Reflect") + get_settings().set(keys[2], "{{ diff }}") + tool, _ = make_tool(monkeypatch, {("gpt-4o", "b"): TimeoutError("failure")}) + del tool._self_reflect_with_fallback + generation = tool.ai_handler.chat_completion + reflections = [] + + async def completion(*, model, system, user, **kwargs): + if system != "Reflect": + return await generation(model=model, system=system, user=user, **kwargs) + reflections.append((model, user, get_settings().get("openai.deployment_id"))) + return yaml.safe_dump({"code_suggestions": [{"suggestion_score": 9, "why": "Verified"}]}), "stop" + + tool.ai_handler.chat_completion = completion + result = await retry_with_fallback_models(tool.prepare_prediction_main) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert all(s["score"] == 9 and s["score_why"] == "Verified" for s in result["code_suggestions"]) + assert ("gpt-4o-mini", "b", "secondary") in reflections + assert len(reflections) == 3 + finally: + restore_settings(snapshot) + + +async def test_routed_primary_recovers_failed_slots_with_configured_fallback(configured, monkeypatch): + # A routed primary ([model_routing]) replaces config.model ahead of the same + # fallbacks; recovery must reproduce that substitution instead of scanning + # the configured chain. The routed model is kept out of the fallback list so + # the (model, deployment) pair stays unique in the effective chain. + get_settings().set("config.fallback_models", ["gpt-4.1"]) + get_settings().set("openai.fallback_deployments", ["last"]) + get_settings().set("model_routing.enable", True) + get_settings().set("model_routing.rules", + [{"model": "gpt-4o-mini", "max_files": 10, "deployment_id": "secondary"}]) + tool, calls = make_tool(monkeypatch, {("gpt-4o-mini", "b"): RuntimeError("failure")}) + tool.git_provider.get_diff_files.return_value = [FilePatchInfo("old()\n", "old()\n", "", c + ".py") for c in "abc"] + result = await retry_with_fallback_models(tool.prepare_prediction_main, + git_provider=tool.git_provider) + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert [m for m, _, _, _ in calls] == ["gpt-4o-mini"] * 3 + ["gpt-4.1"] + assert get_settings().get("openai.deployment_id") == "primary" + tool.git_provider.get_diff_files.assert_called_once_with() + + +async def test_routing_is_not_repeated_during_recovery(configured, monkeypatch): + get_settings().set("config.fallback_models", ["gpt-4.1"]) + get_settings().set("openai.fallback_deployments", ["last"]) + get_settings().set("model_routing.enable", True) + get_settings().set("model_routing.rules", + [{"model": "gpt-4o-mini", "max_files": 10, "deployment_id": "secondary"}]) + tool, calls = make_tool(monkeypatch, {("gpt-4o-mini", "b"): RuntimeError("failure")}) + tool.git_provider.get_diff_files.side_effect = [ + [FilePatchInfo("old()\n", "old()\n", "", c + ".py") for c in "abc"], + RuntimeError("transient routing failure"), + ] + + result = await retry_with_fallback_models(tool.prepare_prediction_main, + git_provider=tool.git_provider) + + assert [s["relevant_file"] for s in result["code_suggestions"]] == ["a.py", "b.py", "c.py"] + assert [m for m, _, _, _ in calls] == ["gpt-4o-mini"] * 3 + ["gpt-4.1"] + tool.git_provider.get_diff_files.assert_called_once_with() diff --git a/tests/unittest/test_telemetry_config.py b/tests/unittest/test_telemetry_config.py index 8f1ada52e2..fa0c91aa23 100644 --- a/tests/unittest/test_telemetry_config.py +++ b/tests/unittest/test_telemetry_config.py @@ -8,13 +8,14 @@ configuration when run through the real validation logic. """ +import os import tomllib from pathlib import Path import pytest import pr_agent -from pr_agent.algo.utils import get_version +from pr_agent.algo.run_output import get_version from pr_agent.telemetry import config as config_module from pr_agent.telemetry.config import VALID_EXPORTER_TYPES, _parse_otlp_headers, get_otel_config from pr_agent.telemetry.types import ExporterType @@ -143,6 +144,36 @@ def test_otlp_timeout_defaults_and_reads_setting(monkeypatch): assert get_otel_config().otlp_timeout == 10 +def test_prometheus_settings_provision_state_dir(monkeypatch, tmp_path): + state_dir = tmp_path / "prometheus-state" + _use_settings(monkeypatch, { + **VALID_ENABLED_SETTINGS, + "OTEL.EXPORTER_TYPE": "prometheus", + "OTEL.PROMETHEUS_MULTIPROC_DIR": str(state_dir), + }) + + config = get_otel_config() + + assert config.is_enabled is True + assert config.exporter_type == ExporterType.PROMETHEUS + assert config.prometheus_multiproc_dir == str(state_dir) + assert os.environ["PROMETHEUS_MULTIPROC_DIR"] == str(state_dir) + assert state_dir.is_dir(), "the multiprocess state dir must be created eagerly" + + +def test_prometheus_without_endpoint_is_not_fail_closed(monkeypatch): + """Prometheus is a local scrape endpoint the operator explicitly selected, + so it must not require an OTLP endpoint like the 'otlp' exporter does.""" + _use_settings(monkeypatch, {**VALID_ENABLED_SETTINGS, "OTEL.EXPORTER_TYPE": "prometheus"}) + + with capture_loguru(level="WARNING") as captured: + config = get_otel_config() + + assert config.is_enabled is True + assert config.exporter_type == ExporterType.PROMETHEUS + assert captured == [], "prometheus must not emit a fail-closed warning" + + def test_otlp_protocol_defaults_to_http_and_reads_setting(monkeypatch): _use_settings(monkeypatch, dict(VALID_ENABLED_SETTINGS)) assert get_otel_config().otlp_protocol == "http" @@ -230,6 +261,7 @@ def test_shipped_configuration_toml_otel_section_values(): assert configuration["environment"] == "development" assert configuration["otlp_timeout"] == 3 assert configuration["otlp_protocol"] == "http", "the default transport must not need an extra" + assert configuration["prometheus_multiproc_dir"] == "/tmp/pr-agent-prometheus" assert configuration["include_pr_url"] is False, "PR URLs must be opt-in (privacy)" assert configuration["include_error_details"] is False, "error details must be opt-in (privacy)" diff --git a/tests/unittest/test_telemetry_exporter_types.py b/tests/unittest/test_telemetry_exporter_types.py index 85fdb00ecc..3fb766e599 100644 --- a/tests/unittest/test_telemetry_exporter_types.py +++ b/tests/unittest/test_telemetry_exporter_types.py @@ -32,7 +32,9 @@ def _reset_telemetry(): def test_valid_exporter_types_equals_constant_set(): - assert VALID_EXPORTER_TYPES == {ExporterType.CONSOLE, ExporterType.OTLP, ExporterType.NONE} + assert VALID_EXPORTER_TYPES == { + ExporterType.CONSOLE, ExporterType.OTLP, ExporterType.PROMETHEUS, ExporterType.NONE + } def test_disabled_tracer_is_noop_never_global(monkeypatch): @@ -64,6 +66,7 @@ def test_exporter_type_constant_values(): users put in configuration.toml.""" assert ExporterType.OTLP == "otlp" assert ExporterType.CONSOLE == "console" + assert ExporterType.PROMETHEUS == "prometheus" assert ExporterType.NONE == "none" @@ -85,6 +88,22 @@ def test_metric_exporter_none(): assert _create_metric_exporter(make_config(exporter_type=ExporterType.NONE)) is None +def test_span_exporter_prometheus_is_none(): + """The prometheus exporter is metrics-only: spans are silently dropped.""" + with capture_loguru(level="WARNING") as captured: + exporter = _create_exporter(make_config(exporter_type=ExporterType.PROMETHEUS)) + + assert exporter is None + assert "metrics only" in "\n".join(captured) + + +def test_metric_exporter_prometheus(): + from pr_agent.telemetry.prometheus import PrometheusMetricExporter + + exporter = _create_metric_exporter(make_config(exporter_type=ExporterType.PROMETHEUS)) + assert isinstance(exporter, PrometheusMetricExporter) + + def test_span_exporter_unknown_returns_none(): assert _create_exporter(make_config(exporter_type="bogus")) is None diff --git a/tests/unittest/test_telemetry_privacy.py b/tests/unittest/test_telemetry_privacy.py index b080f7151c..6b33ed2ee1 100644 --- a/tests/unittest/test_telemetry_privacy.py +++ b/tests/unittest/test_telemetry_privacy.py @@ -10,6 +10,8 @@ inflate the counter. """ +from unittest.mock import Mock + import pytest from opentelemetry.trace import StatusCode @@ -196,6 +198,67 @@ async def test_tool_exception_not_auto_recorded_by_default(telemetry, monkeypatc assert SENTINEL_URL not in _all_exported_text(exporter) +@pytest.mark.asyncio +async def test_incomplete_files_error_keeps_bounded_telemetry_when_notification_fails( + telemetry, monkeypatch +): + exporter, _ = telemetry + provider = Mock() + provider.get_issue_comments_newest_first.return_value = [] + provider.supports_html_comment_markers.return_value = True + provider.publish_comment.side_effect = RuntimeError("notification failed") + + class IncompleteTool: + def __init__(self, pr_url, ai_handler, args): + raise pr_agent_module.IncompletePullRequestFilesError( + f"failed to fetch {SENTINEL_URL}" + ) + + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _url: provider) + monkeypatch.setitem(pr_agent_module.command2class, "customcmd", IncompleteTool) + + handled = await PRAgent(ai_handler="fake")._handle_request( + "https://example/pr/1", ["customcmd"] + ) + + assert handled is False + provider.publish_comment.assert_called_once() + span = exporter.get_finished_spans()[0] + assert dict(span.attributes)["error.type"] == "IncompletePullRequestFilesError" + assert SENTINEL_URL not in _all_exported_text(exporter) + + +@pytest.mark.asyncio +async def test_incomplete_files_error_keeps_primary_telemetry_when_comment_decode_fails( + telemetry, monkeypatch +): + exporter, _ = telemetry + provider = Mock() + provider.get_issue_comments_newest_first.return_value = [object()] + provider._get_comment_body.side_effect = RuntimeError( + f"could not decode comment from {SENTINEL_URL}" + ) + + class IncompleteTool: + def __init__(self, pr_url, ai_handler, args): + raise pr_agent_module.IncompletePullRequestFilesError("primary failure") + + monkeypatch.setattr(get_settings().config, "publish_output", True, raising=False) + monkeypatch.setattr(pr_agent_module, "get_git_provider_with_context", lambda _url: provider) + monkeypatch.setitem(pr_agent_module.command2class, "customcmd", IncompleteTool) + + handled = await PRAgent(ai_handler="fake")._handle_request( + "https://example/pr/1", ["customcmd"] + ) + + assert handled is False + provider.publish_comment.assert_called_once() + span = exporter.get_finished_spans()[0] + assert dict(span.attributes)["error.type"] == "IncompletePullRequestFilesError" + assert SENTINEL_URL not in _all_exported_text(exporter) + + @pytest.mark.asyncio async def test_propagating_exception_recorded_when_opted_in(telemetry, monkeypatch): exporter, _ = telemetry diff --git a/tests/unittest/test_telemetry_prometheus.py b/tests/unittest/test_telemetry_prometheus.py new file mode 100644 index 0000000000..0cb6637062 --- /dev/null +++ b/tests/unittest/test_telemetry_prometheus.py @@ -0,0 +1,132 @@ +"""Tests for the native Prometheus exporter and its `/metrics` endpoint. + +The whole module runs with ``PROMETHEUS_MULTIPROC_DIR`` set (multiprocess +mode), mirroring a gunicorn worker: prometheus_client decides its storage mode +at import time from that env var, and the exporter defers the import to first +use. Nothing else in the telemetry suite imports prometheus_client, so the +env var above drives every process-global decision here. +""" + +import os +import subprocess +import sys + +import pytest +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + +from pr_agent.telemetry.prometheus import ( + PrometheusMetricExporter, + attach_metrics_endpoint, + prometheus_metrics_enabled, + prometheus_response, +) +from pr_agent.telemetry.prometheus_multiproc import ensure_prometheus_multiproc_dir + + +@pytest.fixture(scope="session", autouse=True) +def _multiproc_env(tmp_path_factory): + """Set the multiprocess state dir before the first prometheus_client import.""" + state_dir = tmp_path_factory.mktemp("prometheus-multiproc") + ensure_prometheus_multiproc_dir(str(state_dir)) + yield str(state_dir) + + +def _counter_provider(): + reader = PeriodicExportingMetricReader(PrometheusMetricExporter()) + provider = MeterProvider(metric_readers=[reader]) + return provider, reader + + +def test_counter_export_renders_prometheus_text(): + provider, reader = _counter_provider() + counter = provider.get_meter("prometheus-test").create_counter( + "pr_agent.commands", unit="{command}", description="PR-Agent commands executed" + ) + counter.add(3, {"pr_agent.command": "review", "provider.name": "github"}) + reader.collect() + + body = prometheus_response().body.decode() + # The OTel name "pr_agent.commands" is sanitized (dots -> underscores) and + # Prometheus appends the counter's _total suffix. + assert "# TYPE pr_agent_commands_total counter" in body + assert "# HELP pr_agent_commands_total PR-Agent commands executed" in body + assert 'pr_agent_commands_total{pr_agent_command="review",provider_name="github"} 3.0' in body + + +def test_multiple_workers_aggregate_at_scrape_time(): + """Run the exporter in two fresh interpreter processes (as gunicorn workers + would), each recording one command, and assert /metrics merges them.""" + child_code = "; ".join([ + "from opentelemetry.sdk.metrics import MeterProvider", + "from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader", + "from pr_agent.telemetry.prometheus import PrometheusMetricExporter", + "provider = MeterProvider(metric_readers=[PeriodicExportingMetricReader(PrometheusMetricExporter())])", + ( + "counter = provider.get_meter('mp-test').create_counter(" + "'pr_agent.commands', unit='{command}', description='PR-Agent commands executed')" + ), + "counter.add(1, {'pr_agent.command': 'review'})", + "provider.shutdown()", + ]) + for _ in range(2): + subprocess.run([sys.executable, "-c", child_code], check=True, capture_output=True) + + body = prometheus_response().body.decode() + # Both workers' files are merged into one series at scrape time; the + # parent's own in-process contribution (different label set) also shows. + assert 'pr_agent_commands_total{pr_agent_command="review"} 2.0' in body + assert 'pr_agent_command="review",provider_name="github"}' in body + + +def test_attach_metrics_endpoint_registers_lazy_get_route(): + from fastapi import APIRouter + + router = APIRouter() + attach_metrics_endpoint(router) + + assert len(router.routes) == 1 + route = router.routes[0] + assert route.path == "/metrics" + assert "GET" in route.methods + assert route.include_in_schema is False + # The endpoint resolves prometheus_client only on request — registering the + # route must not pull it in (a gunicorn master importing the app would + # otherwise lock in the wrong multiprocess mode before `when_ready`). + assert callable(route.endpoint) + + +def test_prometheus_metrics_enabled_reads_settings(monkeypatch): + from pr_agent.telemetry import prometheus as prometheus_module + + class FakeSettings: + def __init__(self, values): + self._values = values + + def get(self, key, default=None): + return self._values.get(key, default) + + monkeypatch.setattr(prometheus_module, "get_settings", lambda: FakeSettings({})) + assert prometheus_metrics_enabled() is False + + monkeypatch.setattr(prometheus_module, "get_settings", lambda: FakeSettings( + {"OTEL.IS_ENABLED": True, "OTEL.EXPORTER_TYPE": "prometheus"})) + assert prometheus_metrics_enabled() is True + + monkeypatch.setattr(prometheus_module, "get_settings", lambda: FakeSettings( + {"OTEL.IS_ENABLED": True, "OTEL.EXPORTER_TYPE": "otlp"})) + assert prometheus_metrics_enabled() is False + + +def test_importing_prometheus_bridge_does_not_import_prometheus_client(): + """The module must never import prometheus_client at import time — a gunicorn + master that imports the app under preload_app would otherwise decide the + non-multiprocess storage mode before when_ready provisions the state dir.""" + env = {k: v for k, v in os.environ.items() if k != "PROMETHEUS_MULTIPROC_DIR"} + code = ( + "import sys; " + "from pr_agent.telemetry.prometheus import prometheus_metrics_enabled, " + "attach_metrics_endpoint; " + "assert 'prometheus_client' not in sys.modules" + ) + subprocess.run([sys.executable, "-c", code], check=True, capture_output=True, env=env) diff --git a/tests/unittest/test_telemetry_prometheus_multiproc.py b/tests/unittest/test_telemetry_prometheus_multiproc.py new file mode 100644 index 0000000000..146199876c --- /dev/null +++ b/tests/unittest/test_telemetry_prometheus_multiproc.py @@ -0,0 +1,59 @@ +"""Tests for the prometheus multiprocess state directory provisioning. + +The state directory is the one piece of the exporter the gunicorn master +touches before workers import prometheus_client, and a predictable default in +a world-writable prefix like /tmp must not be redirectable to a foreign +location or shared with other users. These tests lock in the symlink and +ownership refusals and the 0700 creation. +""" + +import os +import stat + +import pytest + +from pr_agent.telemetry.prometheus_multiproc import ( + PROMETHEUS_MULTIPROC_DIR_ENV, + ensure_prometheus_multiproc_dir, +) + + +def test_fresh_dir_created_0700_and_env_set(tmp_path, monkeypatch): + monkeypatch.setenv(PROMETHEUS_MULTIPROC_DIR_ENV, "stale") + state_dir = tmp_path / "prometheus-state" + + result = ensure_prometheus_multiproc_dir(str(state_dir)) + + assert result == str(state_dir) + assert os.environ[PROMETHEUS_MULTIPROC_DIR_ENV] == str(state_dir) + assert state_dir.is_dir() + assert stat.S_IMODE(state_dir.stat().st_mode) == 0o700 + + +def test_existing_owned_dir_accepted_and_tightened_to_0700(tmp_path): + state_dir = tmp_path / "prometheus-state" + state_dir.mkdir(mode=0o755) + + ensure_prometheus_multiproc_dir(str(state_dir)) + + assert stat.S_IMODE(state_dir.stat().st_mode) == 0o700 + + +def test_symlink_is_refused(tmp_path): + real = tmp_path / "real-state" + real.mkdir() + link = tmp_path / "prometheus-state" + link.symlink_to(real) + + with pytest.raises(RuntimeError, match="symlinked"): + ensure_prometheus_multiproc_dir(str(link)) + + +def test_dir_owned_by_foreign_uid_is_refused(tmp_path, monkeypatch): + state_dir = tmp_path / "prometheus-state" + state_dir.mkdir() + foreign_uid = os.getuid() + 1 + monkeypatch.setattr("pr_agent.telemetry.prometheus_multiproc.os.getuid", lambda: foreign_uid) + + with pytest.raises(RuntimeError, match="owned by uid"): + ensure_prometheus_multiproc_dir(str(state_dir)) diff --git a/tests/unittest/test_ticket_compliance.py b/tests/unittest/test_ticket_compliance.py index 2fe8bbf1d8..30801e9462 100644 --- a/tests/unittest/test_ticket_compliance.py +++ b/tests/unittest/test_ticket_compliance.py @@ -6,6 +6,8 @@ - Authenticated task fetching and provider integration - Edge cases (mixed content, no tickets, duplicates, API failures) """ +import asyncio + import pytest from pr_agent.config_loader import get_settings @@ -360,6 +362,41 @@ async def test_extract_tickets_caps_non_github_asana_references(self): for ticket in tickets ) + @pytest.mark.asyncio + async def test_fallback_asana_tickets_count_against_jira_budget(self, monkeypatch): + """The fallback path shares MAX_TICKETS between Asana and Jira results, + matching the provider-specific paths that pass their existing tickets in.""" + jira_keys = ["jira.jira_site", "jira.jira_api_email", "jira.jira_api_token"] + snapshot = _settings_helpers.snapshot_settings(jira_keys) + try: + get_settings().set("jira.jira_site", "example") + get_settings().set("jira.jira_api_email", "user@example.com") + get_settings().set("jira.jira_api_token", "test-token") + + class _FakeJira: + def issue(self, key): + return {"fields": {"summary": f"Ticket {key}", "description": "body", + "labels": []}} + + monkeypatch.setattr(tpc, "Jira", lambda **kwargs: _FakeJira()) + provider = _GenericProvider( + "Related Asana tasks: " + "https://app.asana.com/0/99/111111111111 " + "https://app.asana.com/0/99/222222222222 " + "https://app.asana.com/0/99/333333333333 " + "Also PROJ-1 PROJ-2 PROJ-3 PROJ-4 PROJ-5" + ) + + tickets = await tpc.extract_tickets(provider) + + assert len(tickets) == tpc.MAX_TICKETS + assert [t["ticket_id"] for t in tickets[:3]] == [ + "111111111111", "222222222222", "333333333333" + ] + assert [t["ticket_id"] for t in tickets[3:]] == ["PROJ-1", "PROJ-2"] + finally: + _settings_helpers.restore_settings(snapshot) + @pytest.mark.asyncio async def test_extract_tickets_adds_asana_without_truncating_azure(self): """Azure work items remain intact while Asana additions keep their own cap.""" @@ -504,6 +541,152 @@ async def _failing_fetch(_session, ticket_url, _max_body_characters): assert tickets == [] assert attempted_urls == ticket_urls[:3] + @pytest.mark.asyncio + async def test_asana_ticket_fetches_run_concurrently_and_preserve_input_order(self, monkeypatch): + active_fetches = 0 + max_active_fetches = 0 + all_started = asyncio.Event() + + async def _coordinated_fetch(_session, ticket_url, _max_body_characters): + nonlocal active_fetches, max_active_fetches + active_fetches += 1 + max_active_fetches = max(max_active_fetches, active_fetches) + if active_fetches == 3: + all_started.set() + try: + await asyncio.wait_for(all_started.wait(), timeout=0.1) + task_gid = tpc._get_asana_task_gid(ticket_url) + return { + "ticket_id": task_gid, + "ticket_url": ticket_url, + "title": f"Asana task {task_gid}", + "body": "", + "labels": "", + } + finally: + active_fetches -= 1 + + monkeypatch.setattr(tpc, "_fetch_asana_ticket_content", _coordinated_fetch) + ticket_urls = [ + f"https://app.asana.com/0/99/{task_gid}" + for task_gid in (111111111111, 222222222222, 333333333333) + ] + + tickets = await tpc._fetch_asana_ticket_contents(ticket_urls, 3, 10000) + + assert max_active_fetches == 3 + assert [ticket["ticket_url"] for ticket in tickets] == ticket_urls + + @pytest.mark.asyncio + async def test_asana_ticket_fetches_keep_successes_ordered_after_partial_failure(self, monkeypatch): + async def _partially_failing_fetch(_session, ticket_url, _max_body_characters): + task_gid = tpc._get_asana_task_gid(ticket_url) + await asyncio.sleep({"111111111111": 0.02, "222222222222": 0, "333333333333": 0.01}[task_gid]) + if task_gid == "222222222222": + raise RuntimeError("task unavailable") + return { + "ticket_id": task_gid, + "ticket_url": ticket_url, + "title": f"Asana task {task_gid}", + "body": "", + "labels": "", + } + + monkeypatch.setattr(tpc, "_fetch_asana_ticket_content", _partially_failing_fetch) + ticket_urls = [ + f"https://app.asana.com/0/99/{task_gid}" + for task_gid in (111111111111, 222222222222, 333333333333) + ] + + tickets = await tpc._fetch_asana_ticket_contents(ticket_urls, 3, 10000) + + assert [ticket["ticket_id"] for ticket in tickets] == ["111111111111", "333333333333"] + + @pytest.mark.asyncio + async def test_asana_ticket_timeout_does_not_cancel_other_fetches(self, monkeypatch): + async def _timeout_one_fetch(_session, ticket_url, _max_body_characters): + task_gid = tpc._get_asana_task_gid(ticket_url) + if task_gid == "222222222222": + raise TimeoutError("task timed out") + return { + "ticket_id": task_gid, + "ticket_url": ticket_url, + "title": f"Asana task {task_gid}", + "body": "", + "labels": "", + } + + monkeypatch.setattr(tpc, "_fetch_asana_ticket_content", _timeout_one_fetch) + ticket_urls = [ + f"https://app.asana.com/0/99/{task_gid}" + for task_gid in (111111111111, 222222222222, 333333333333) + ] + + tickets = await tpc._fetch_asana_ticket_contents(ticket_urls, 3, 10000) + + assert [ticket["ticket_id"] for ticket in tickets] == ["111111111111", "333333333333"] + + @pytest.mark.asyncio + async def test_asana_ticket_batch_cancellation_cleans_up_in_flight_fetches(self, monkeypatch): + active_fetches = 0 + all_started = asyncio.Event() + + async def _blocking_fetch(_session, _ticket_url, _max_body_characters): + nonlocal active_fetches + active_fetches += 1 + if active_fetches == 3: + all_started.set() + try: + await asyncio.Event().wait() + finally: + active_fetches -= 1 + + monkeypatch.setattr(tpc, "_fetch_asana_ticket_content", _blocking_fetch) + ticket_urls = [ + f"https://app.asana.com/0/99/{task_gid}" + for task_gid in (111111111111, 222222222222, 333333333333) + ] + batch = asyncio.create_task(tpc._fetch_asana_ticket_contents(ticket_urls, 3, 10000)) + await asyncio.wait_for(all_started.wait(), timeout=0.1) + + batch.cancel() + with pytest.raises(asyncio.CancelledError): + await batch + + assert active_fetches == 0 + + @pytest.mark.asyncio + async def test_asana_ticket_child_abort_cancels_and_drains_siblings(self, monkeypatch): + class FetchAborted(BaseException): + pass + + active_fetches = 0 + all_started = asyncio.Event() + + async def _aborting_fetch(_session, ticket_url, _max_body_characters): + nonlocal active_fetches + active_fetches += 1 + if active_fetches == 3: + all_started.set() + try: + await asyncio.wait_for(all_started.wait(), timeout=0.1) + if tpc._get_asana_task_gid(ticket_url) == "111111111111": + raise FetchAborted("child fetch aborted") + await asyncio.Event().wait() + finally: + active_fetches -= 1 + + monkeypatch.setattr(tpc, "_fetch_asana_ticket_content", _aborting_fetch) + ticket_urls = [ + f"https://app.asana.com/0/99/{task_gid}" + for task_gid in (111111111111, 222222222222, 333333333333) + ] + + with pytest.raises(FetchAborted, match="child fetch aborted"): + await tpc._fetch_asana_ticket_contents(ticket_urls, 3, 10000) + + assert active_fetches == 0 + @pytest.mark.asyncio async def test_invalid_asana_url_does_not_abort_valid_batch_entry(self, monkeypatch): attempted_urls = [] diff --git a/tests/unittest/test_ticket_extraction_async.py b/tests/unittest/test_ticket_extraction_async.py index b222679546..f154a77d37 100644 --- a/tests/unittest/test_ticket_extraction_async.py +++ b/tests/unittest/test_ticket_extraction_async.py @@ -31,11 +31,16 @@ def __init__(self, name): class _FakeIssue: - def __init__(self, number, title="t", body="b", labels=None): + def __init__(self, number, title="t", body="b", labels=None, raw_data=None): self.number = number self.title = title self.body = body self.labels = labels if labels is not None else [] + self.raw_data = raw_data or {} + + @property + def pull_request(self): + return self.raw_data.get("pull_request") class _FakeRepoObj: @@ -155,6 +160,53 @@ def settings_snapshot(): # --------------------------------------------------------------------------- class TestGithubExtractionMerging: + def test_skipped_pr_does_not_displace_later_issue(self, settings_snapshot): + repo_obj = _FakeRepoObj({ + 1: _FakeIssue(1, raw_data={"pull_request": {"url": "pr"}}), + **{number: _FakeIssue(number) for number in range(2, 6)}, + }) + provider = _make_github_provider(user_description="#1 #2 #3 #4 #5", repo_obj=repo_obj) + repo_obj.get_issue = MagicMock(wraps=repo_obj.get_issue) + result = asyncio.run(extract_tickets(provider)) + assert [ticket["ticket_id"] for ticket in result] == [2, 3, 4] + assert [call.args[0] for call in repo_obj.get_issue.call_args_list] == [1, 2, 3, 4] + + def test_malformed_url_does_not_discard_surrounding_issues(self, settings_snapshot): + provider = _make_github_provider( + user_description=f"#1 https://github.com/org/repo/issues/{'9' * 4301} #2", + repo_obj=_FakeRepoObj({1: _FakeIssue(1), 2: _FakeIssue(2)}), + ) + result = asyncio.run(extract_tickets(provider)) + assert [ticket["ticket_id"] for ticket in result] == [1, 2] + + def test_pr_lookup_attempts_are_bounded(self, settings_snapshot): + repo_obj = _FakeRepoObj({ + number: _FakeIssue(number, raw_data={"pull_request": {"url": "pr"}}) + for number in range(1, 100) + }) + repo_obj.get_issue = MagicMock(wraps=repo_obj.get_issue) + provider = _make_github_provider( + user_description=" ".join(f"#{number}" for number in range(1, 100)), repo_obj=repo_obj, + ) + assert asyncio.run(extract_tickets(provider)) == [] + assert repo_obj.get_issue.call_count == tpc.MAX_GITHUB_TICKET_LOOKUPS + + def test_pull_request_reference_is_skipped(self, settings_snapshot): + repo_obj = _FakeRepoObj({ + 56: _FakeIssue(56, raw_data={"pull_request": {"url": "https://api.github.com/repos/org/repo/pulls/56"}}), + 123: _FakeIssue(123, title="Real issue"), + }) + provider = _make_github_provider( + user_description="Related PR #56. Fixes #123.", + repo_obj=repo_obj, + ) + provider.fetch_sub_issues = MagicMock(return_value=[]) + + result = asyncio.run(extract_tickets(provider)) + + assert [ticket["ticket_id"] for ticket in result] == [123] + provider.fetch_sub_issues.assert_called_once_with("https://github.com/org/repo/issues/123") + def test_branch_extraction_contributes_ticket_not_in_description(self, settings_snapshot): # Description mentions only #1; branch contributes #2. Without branch # extraction the result would be [1]; with it, [1, 2] (description first). @@ -679,7 +731,7 @@ def test_issue_reference_is_mapped_to_ticket_context(self, reference, settings_s "labels": "bug, backend", } ] - provider.gl.projects.get.assert_called_once_with("group/repo") + provider.gl.projects.get.assert_called_once_with("group/repo", lazy=True) project.issues.get.assert_called_once_with(7) diff --git a/tests/unittest/test_ticket_pr_compliance_capabilities.py b/tests/unittest/test_ticket_pr_compliance_capabilities.py new file mode 100644 index 0000000000..382201fb00 --- /dev/null +++ b/tests/unittest/test_ticket_pr_compliance_capabilities.py @@ -0,0 +1,387 @@ +from unittest.mock import MagicMock + +import pytest + +from pr_agent.git_providers import ( + AzureDevopsProvider, + BitbucketProvider, + BitbucketServerProvider, + CodeCommitProvider, + GerritProvider, + GiteaProvider, + GithubProvider, + GitLabProvider, + GitProvider, + LocalGitProvider, + PlainDiffGitProvider, +) +from pr_agent.tools.ticket_pr_compliance_check import _provider_supports, extract_tickets + + +class _BaseStubProvider(GitProvider): + def is_supported(self, capability: str) -> bool: + return False + + def get_files(self) -> list: + return [] + + def get_diff_files(self) -> list: + return [] + + def publish_description(self, pr_title: str, pr_body: str): + pass + + def publish_code_suggestions(self, code_suggestions: list) -> bool: + return False + + def get_languages(self): + return {} + + def get_pr_branch(self): + return "" + + def get_user_id(self): + return "" + + def get_pr_description_full(self) -> str: + return "" + + def get_repo_settings(self): + return b"" + + def publish_comment(self, pr_comment: str, is_temporary: bool = False): + pass + + def publish_inline_comment( + self, body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None + ): + pass + + def publish_inline_comments(self, comments: list[dict]): + pass + + def remove_initial_comment(self): + pass + + def remove_comment(self, comment): + pass + + def get_issue_comments(self): + return [] + + def publish_labels(self, labels): + pass + + def get_pr_labels(self, update=False): + return [] + + def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: + return False + + def get_commit_messages(self) -> str: + return "" + + +class _CustomGitHubSubstitute(_BaseStubProvider): + def __init__(self, description="Fixes #101", branch="feature/102-branch"): + self.repo = "owner/repo" + self.base_url_html = "https://github.com" + self._description = description + self._branch = branch + self.github_client = MagicMock() + self.repo_obj = MagicMock() + + issue_101 = MagicMock(number=101, title="Issue 101", body="Body 101", labels=["bug"], pull_request=None) + issue_102 = MagicMock(number=102, title="Issue 102", body="Body 102", labels=["core"], pull_request=None) + self.repo_obj.get_issue.side_effect = lambda num: {101: issue_101, 102: issue_102}[num] + + def supports_issue_url_tickets(self) -> bool: + return True + + def get_user_description(self) -> str: + return self._description + + def get_pr_branch(self) -> str: + return self._branch + + def _parse_issue_url(self, url: str): + num = int(url.rsplit("/", 1)[-1]) + return self.repo, num + + def fetch_sub_issues(self, ticket_url: str) -> list: + return [] + + +class _CustomGitLabSubstitute(_BaseStubProvider): + def __init__(self, description="Resolves group/project#55"): + self.id_project = "group/project" + self.gitlab_url = "https://gitlab.example.com" + self._description = description + self.gl = MagicMock() + + issue = MagicMock( + iid=55, + web_url="https://gitlab.example.com/group/project/-/issues/55", + title="GitLab Issue 55", + description="GitLab Description 55", + labels=["backend", "p1"], + ) + project = MagicMock() + project.issues.get.return_value = issue + self.gl.projects.get.return_value = project + + def supports_issue_reference_tickets(self) -> bool: + return True + + def get_user_description(self) -> str: + return self._description + + +class _CustomAzureSubstitute(_BaseStubProvider): + def __init__(self, work_items=None): + self._work_items = ( + work_items + if work_items is not None + else [ + { + "id": 999, + "url": "https://dev.azure.com/org/proj/_workitems/edit/999", + "title": "Work Item 999", + "body": "Work item body 999", + "acceptance_criteria": "AC 999", + "labels": ["azure-label"], + } + ] + ) + + def supports_linked_work_item_tickets(self) -> bool: + return True + + def get_linked_work_items(self) -> list: + return self._work_items + + +class _IndependentDuckTypedSubstitute: + def __init__(self, capability: str): + self._capability = capability + self.repo = "duck/repo" + self.base_url_html = "https://github.com" + self.id_project = "duck/repo" + self.gitlab_url = "https://gitlab.com" + self.repo_obj = MagicMock() + self.gl = MagicMock() + + def supports_issue_url_tickets(self) -> bool: + return self._capability == "github" + + def supports_issue_reference_tickets(self) -> bool: + return self._capability == "gitlab" + + def supports_linked_work_item_tickets(self) -> bool: + return self._capability == "azure" + + def get_user_description(self) -> str: + if self._capability == "github": + return "Fixes #42" + if self._capability == "gitlab": + return "Fixes duck/repo#42" + return "" + + def get_pr_branch(self) -> str: + return "main" + + def _parse_issue_url(self, url: str): + return self.repo, 42 + + def fetch_sub_issues(self, ticket_url: str) -> list: + return [] + + def get_linked_work_items(self) -> list: + return [ + { + "id": 42, + "url": "https://dev.azure.com/duck/repo/_workitems/edit/42", + "title": "Azure 42", + "body": "Body 42", + "acceptance_criteria": "", + "labels": [], + } + ] + + +def test_base_git_provider_capabilities_default_false(): + provider = _BaseStubProvider() + assert provider.supports_issue_url_tickets() is False + assert provider.supports_issue_reference_tickets() is False + assert provider.supports_linked_work_item_tickets() is False + + +@pytest.mark.parametrize( + ("provider_cls", "expected_github", "expected_gitlab", "expected_azure"), + [ + (GithubProvider, True, False, False), + (GitLabProvider, False, True, False), + (AzureDevopsProvider, False, False, True), + (BitbucketProvider, False, False, False), + (BitbucketServerProvider, False, False, False), + (CodeCommitProvider, False, False, False), + (LocalGitProvider, False, False, False), + (GerritProvider, False, False, False), + (GiteaProvider, False, False, False), + (PlainDiffGitProvider, False, False, False), + ], +) +def test_concrete_providers_declare_expected_ticket_capabilities( + provider_cls, expected_github, expected_gitlab, expected_azure +): + provider = provider_cls.__new__(provider_cls) + assert provider.supports_issue_url_tickets() is expected_github + assert provider.supports_issue_reference_tickets() is expected_gitlab + assert provider.supports_linked_work_item_tickets() is expected_azure + + +@pytest.mark.asyncio +async def test_extract_tickets_routes_to_github_capability(): + provider = _CustomGitHubSubstitute() + tickets = await extract_tickets(provider) + + assert tickets is not None + assert len(tickets) == 2 + assert tickets[0]["ticket_id"] == 101 + assert tickets[0]["title"] == "Issue 101" + assert tickets[1]["ticket_id"] == 102 + assert tickets[1]["title"] == "Issue 102" + + +@pytest.mark.asyncio +async def test_extract_tickets_routes_to_gitlab_capability(): + provider = _CustomGitLabSubstitute() + tickets = await extract_tickets(provider) + + assert tickets is not None + assert len(tickets) == 1 + assert tickets[0]["ticket_id"] == 55 + assert tickets[0]["title"] == "GitLab Issue 55" + assert tickets[0]["body"] == "GitLab Description 55" + assert tickets[0]["labels"] == "backend, p1" + + +@pytest.mark.asyncio +async def test_extract_tickets_routes_to_azure_capability(): + provider = _CustomAzureSubstitute() + tickets = await extract_tickets(provider) + + assert tickets is not None + assert len(tickets) == 1 + assert tickets[0]["ticket_id"] == 999 + assert tickets[0]["title"] == "Work Item 999" + assert tickets[0]["body"] == "Work item body 999" + assert tickets[0]["requirements"] == "AC 999" + assert tickets[0]["labels"] == "azure-label" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("route", ["github", "gitlab", "azure"]) +async def test_extract_tickets_supports_duck_typed_substitutes(route): + provider = _IndependentDuckTypedSubstitute(route) + if route == "github": + provider.repo_obj.get_issue.return_value = MagicMock( + number=42, title="GH 42", body="GH Body", labels=[], pull_request=None + ) + elif route == "gitlab": + mock_proj = MagicMock() + mock_proj.issues.get.return_value = MagicMock( + iid=42, web_url="https://gitlab.com/duck/repo/-/issues/42", title="GL 42", description="GL Body", labels=[] + ) + provider.gl.projects.get.return_value = mock_proj + + tickets = await extract_tickets(provider) + assert tickets is not None + assert len(tickets) == 1 + assert tickets[0]["ticket_id"] == 42 + + +@pytest.mark.asyncio +async def test_extract_tickets_unsupported_provider_returns_none_without_asana(): + provider = _BaseStubProvider() + provider.get_user_description = lambda: "No tickets here" + result = await extract_tickets(provider) + assert result is None + + +@pytest.mark.asyncio +async def test_extract_tickets_unsupported_provider_preserves_asana_results(monkeypatch): + provider = _BaseStubProvider() + provider.get_user_description = lambda: "Related: https://app.asana.com/0/99/123456789012" + + async def fake_fetch(urls, max_tickets, max_characters): + assert urls == ["https://app.asana.com/0/99/123456789012"] + return [{"ticket_id": "123456789012", "ticket_url": urls[0]}] + + monkeypatch.setattr("pr_agent.tools.ticket_pr_compliance_check._fetch_asana_ticket_contents", fake_fetch) + tickets = await extract_tickets(provider) + assert tickets == [{"ticket_id": "123456789012", "ticket_url": "https://app.asana.com/0/99/123456789012"}] + + +@pytest.mark.asyncio +async def test_extract_tickets_github_substitute_handles_issue_fetch_error(): + provider = _CustomGitHubSubstitute(description="Fixes #999", branch="") + provider.repo_obj.get_issue.side_effect = RuntimeError("Issue not found") + tickets = await extract_tickets(provider) + assert tickets == [] + + +@pytest.mark.asyncio +async def test_extract_tickets_gitlab_substitute_handles_project_error(): + provider = _CustomGitLabSubstitute() + provider.gl.projects.get.side_effect = RuntimeError("GitLab API error") + tickets = await extract_tickets(provider) + assert tickets == [] + + +@pytest.mark.asyncio +async def test_extract_tickets_azure_substitute_handles_item_error(): + provider = _CustomAzureSubstitute(work_items=[{"invalid": "data"}]) + tickets = await extract_tickets(provider) + assert len(tickets) == 1 + assert tickets[0]["ticket_id"] is None + + +@pytest.mark.asyncio +async def test_extract_tickets_catches_top_level_exception(): + provider = _CustomGitHubSubstitute() + provider.supports_issue_url_tickets = MagicMock(side_effect=RuntimeError("unexpected crash")) + tickets = await extract_tickets(provider) + assert tickets == [] + + +def test_provider_supports_raises_for_unknown_capability(): + provider = _BaseStubProvider() + with pytest.raises(AttributeError, match="unknown provider capability: 'supports_nonexistent_capability'"): + _provider_supports(provider, "supports_nonexistent_capability") + + +def test_provider_supports_returns_false_for_missing_method_on_duck_typed_provider(): + duck_typed_provider = object() + assert _provider_supports(duck_typed_provider, "supports_issue_url_tickets") is False + + +def test_provider_supports_returns_false_for_non_callable_attribute(): + class AttributeProvider: + supports_issue_url_tickets = True + + assert _provider_supports(AttributeProvider(), "supports_issue_url_tickets") is False + + +def test_provider_supports_reads_callable_on_base_or_duck_typed_provider(): + class TruthyProvider: + def supports_issue_url_tickets(self): + return 1 + + class FalsyProvider: + def supports_issue_url_tickets(self): + return 0 + + assert _provider_supports(TruthyProvider(), "supports_issue_url_tickets") is True + assert _provider_supports(FalsyProvider(), "supports_issue_url_tickets") is False + assert _provider_supports(_BaseStubProvider(), "supports_issue_url_tickets") is False diff --git a/tests/unittest/test_token_handler.py b/tests/unittest/test_token_handler.py index d85fa1a6e2..431bb943a2 100644 --- a/tests/unittest/test_token_handler.py +++ b/tests/unittest/test_token_handler.py @@ -6,6 +6,17 @@ from pr_agent.algo import token_handler +def _settings(model="primary-model", estimate_factor=0, openai_key=None, anthropic_key=None): + return SimpleNamespace( + config=SimpleNamespace(model=model), + get=lambda key, default=None: { + "openai.key": openai_key, + "anthropic.key": anthropic_key, + "config.model_token_count_estimate_factor": estimate_factor, + }.get(key, default), + ) + + def test_oversized_claude_patch_falls_back_to_local_estimate(monkeypatch): settings = SimpleNamespace( config=SimpleNamespace(model="claude-3-7-sonnet-20250219"), @@ -30,3 +41,94 @@ def test_oversized_claude_patch_falls_back_to_local_estimate(monkeypatch): assert handler.count_tokens("abcd", force_accurate=True) == 13 client.messages.count_tokens.assert_not_called() + + +def test_no_pr_handler_initializes_zero_prompt_tokens(monkeypatch): + monkeypatch.setattr(token_handler, "get_settings", lambda use_context=True: _settings()) + monkeypatch.setattr( + token_handler.TokenEncoder, + "get_token_encoder", + lambda _model=None: MagicMock(), + ) + + handler = token_handler.TokenHandler(model="fallback-model") + + assert handler.model == "fallback-model" + assert handler.prompt_tokens == 0 + + +def test_for_model_rebinds_rendered_prompt_without_mutating_source(monkeypatch): + monkeypatch.setattr(token_handler, "get_settings", lambda use_context=True: _settings()) + encoders = {} + + class CharacterEncoder: + def __init__(self, model): + self.model = model + + @staticmethod + def encode(text, disallowed_special=()): + return list(text) + + def get_encoder(model=None): + encoders.setdefault(model, CharacterEncoder(model)) + return encoders[model] + + monkeypatch.setattr(token_handler.TokenEncoder, "get_token_encoder", get_encoder) + variables = {"title": "PR"} + handler = token_handler.TokenHandler(object(), variables, "system {{ title }}", "user") + + fallback_handler = handler.for_model("fallback-model") + + assert handler.for_model("primary-model") is handler + assert handler.model == "primary-model" + assert fallback_handler is not handler + assert fallback_handler.model == "fallback-model" + assert fallback_handler.vars is variables + assert fallback_handler.encoder.model == "fallback-model" + assert fallback_handler.prompt_tokens == len("system PR") + len("user") + + +def test_for_model_does_not_replace_configured_primary_encoder_cache(monkeypatch): + monkeypatch.setattr(token_handler, "get_settings", lambda use_context=True: _settings()) + monkeypatch.setattr(token_handler.TokenEncoder, "_encoder_instance", None) + monkeypatch.setattr(token_handler.TokenEncoder, "_model", None) + created_models = [] + + def create_encoder(model): + created_models.append(model) + encoder = MagicMock() + encoder.encode.return_value = [] + return encoder + + monkeypatch.setattr( + token_handler.TokenEncoder, + "_create_encoder", + staticmethod(create_encoder), + ) + handler = token_handler.TokenHandler(object(), {}, "system", "user") + primary_encoder = token_handler.TokenEncoder._encoder_instance + + fallback_handler = handler.for_model("fallback-model") + + assert fallback_handler.encoder is not primary_encoder + assert token_handler.TokenEncoder._encoder_instance is primary_encoder + assert token_handler.TokenEncoder._model == "primary-model" + assert created_models == ["primary-model", "fallback-model"] + + +def test_force_accurate_count_uses_bound_attempt_model(monkeypatch): + settings = _settings( + model="claude-3-7-sonnet-20250219", + estimate_factor=0.3, + openai_key="test-key", + anthropic_key="test-key", + ) + monkeypatch.setattr(token_handler, "get_settings", lambda use_context=True: settings) + handler = token_handler.TokenHandler.__new__(token_handler.TokenHandler) + handler.model = "gpt-4o" + handler.encoder = MagicMock() + handler.encoder.encode.return_value = [0] * 10 + handler._calc_claude_tokens = MagicMock(return_value=99) + + assert handler.count_tokens("patch", force_accurate=True) == 10 + handler._calc_claude_tokens.assert_not_called() diff --git a/tests/unittest/test_tool_capability_guard_contract.py b/tests/unittest/test_tool_capability_guard_contract.py new file mode 100644 index 0000000000..85cbe244a2 --- /dev/null +++ b/tests/unittest/test_tool_capability_guard_contract.py @@ -0,0 +1,164 @@ +"""Contract: no tool may reach a git-provider method its provider has declared unsupported. + +`test_git_provider_method_contract.py` pins what providers DO for a given capability; +this file pins what CALLERS in `pr_agent/tools/` do with the answer. A tool that calls a +capability-gated `self.git_provider.(...)` without checking `is_supported()` first +raises (or silently corrupts output) on any provider that declines that capability: #2932 +was exactly this, for `get_labels` in `pr_reviewer.py`. + +Scope is intentionally the ENCLOSING FUNCTION, not the enclosing class: a guard several +frames away (a flag cached in `__init__`, a raise before a later call) is real, but it is +also invisible to the next person editing the callee, which is the failure mode this test +exists to catch. Every current call site either already checks locally or was given a +local check by this change (see `pr_update_changelog.py::_push_changelog_update` and +`pr_reviewer.py::_get_user_answers`) precisely so this test can be scoped this way without +false positives. +""" +import ast +from pathlib import Path + +TOOLS_DIR = Path(__file__).resolve().parent.parent.parent / "pr_agent" / "tools" +PROVIDERS_DIR = Path(__file__).resolve().parent.parent.parent / "pr_agent" / "git_providers" + +# Base GitProvider methods that at least one provider declines (see +# `_provider_declinable_capabilities` below), mapped to the `is_supported()` capability +# string that guards each. This mapping is domain knowledge (e.g. "get_labels covers +# publish_labels and get_pr_labels") and cannot be derived from the providers alone, so +# unlike the capability set below it is maintained by hand. +GUARDED_METHODS = { + "get_pr_labels": "get_labels", + "publish_labels": "get_labels", + "publish_inline_comments": "publish_inline_comments", + "create_inline_comment": "create_inline_comment", + "get_issue_comments": "get_issue_comments", + "create_or_update_pr_file": "push_code", +} + + +def _tool_files(): + return sorted(TOOLS_DIR.glob("*.py")) + + +def _is_supported_capability_strings(node): + """Every literal string passed as the first arg to an `is_supported(...)` call + anywhere under `node` (a module, class or function node).""" + caps = set() + for inner in ast.walk(node): + if (isinstance(inner, ast.Call) + and isinstance(inner.func, ast.Attribute) + and inner.func.attr == "is_supported" + and inner.args + and isinstance(inner.args[0], ast.Constant) + and isinstance(inner.args[0].value, str)): + caps.add(inner.args[0].value) + return caps + + +def _is_git_provider(node): + """`self.git_provider`, or a bare `git_provider` name in a helper that takes the + provider as a parameter (the pattern `pr_code_suggestions.py` already uses).""" + if isinstance(node, ast.Name): + return node.id == "git_provider" + return (isinstance(node, ast.Attribute) + and node.attr == "git_provider" + and isinstance(node.value, ast.Name) + and node.value.id == "self") + + +def _git_provider_call_sites(func_node): + """Every `self.git_provider.(...)` or `git_provider.(...)` call + directly under a function node, keyed by method name.""" + calls = {} + for inner in ast.walk(func_node): + if (isinstance(inner, ast.Call) + and isinstance(inner.func, ast.Attribute) + and _is_git_provider(inner.func.value)): + calls.setdefault(inner.func.attr, []).append(inner) + return calls + + +def _function_nodes(tree): + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + yield node + + +def _unguarded_call_sites(): + """(file, function, method, lineno) for every capability-gated + `self.git_provider.(...)` call with no matching `is_supported()` check + anywhere in its own enclosing function. + + This is membership, not control flow: it does not check that the guard runs + before the call, sits in the branch that actually reaches it, or that its result + is used. A guard placed after the call, in an unrelated branch, or with its + result ignored still satisfies this check. It catches a capability check being + absent from the function entirely, which is the regression this test guards + against; it will not catch one being present but misplaced. + """ + hits = [] + for path in _tool_files(): + tree = ast.parse(path.read_text(), filename=str(path)) + for func_node in _function_nodes(tree): + guarded_caps = _is_supported_capability_strings(func_node) + calls = _git_provider_call_sites(func_node) + for method, cap in GUARDED_METHODS.items(): + for call in calls.get(method, []): + if cap not in guarded_caps: + hits.append((path.name, func_node.name, method, call.lineno)) + return hits + + +def _provider_declinable_capabilities(): + """Every capability string at least one provider's `is_supported()` special-cases. + + Parsed from `pr_agent/git_providers/*.py` rather than hardcoded, so a new provider + declining a new capability grows this set (and the typo check below) automatically + instead of it silently going stale. + + Collects any string literal compared inside `is_supported()`, without checking + which branch it feeds or whether that branch returns False. A capability named in + a comparison that always returns True, or in an unrelated comparison, is treated + as declinable the same as one a provider genuinely rejects. + """ + caps = set() + for path in sorted(PROVIDERS_DIR.glob("*.py")): + tree = ast.parse(path.read_text(), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "is_supported": + for inner in ast.walk(node): + if isinstance(inner, ast.Compare): + for comparator in inner.comparators: + if isinstance(comparator, ast.Constant) and isinstance(comparator.value, str): + caps.add(comparator.value) + elif isinstance(comparator, (ast.List, ast.Tuple, ast.Set)): + for elt in comparator.elts: + if isinstance(elt, ast.Constant) and isinstance(elt.value, str): + caps.add(elt.value) + return caps + + +def _is_supported_strings_used_in_tools(): + caps = set() + for path in _tool_files(): + tree = ast.parse(path.read_text(), filename=str(path)) + caps |= _is_supported_capability_strings(tree) + return caps + + +def test_every_capability_gated_provider_call_is_locally_guarded(): + hits = _unguarded_call_sites() + assert hits == [], ( + "self.git_provider.() reachable without checking is_supported() in the " + "same function first: " + + ", ".join(f"{f}:{fn}() calls {m}() at line {ln}" for f, fn, m, ln in hits) + ) + + +def test_every_is_supported_string_in_tools_is_a_capability_some_provider_declines(): + used = _is_supported_strings_used_in_tools() + declinable = _provider_declinable_capabilities() + typos = used - declinable + assert not typos, ( + f"is_supported() called in pr_agent/tools/ with a string no provider ever " + f"declines, so the check always passes: {typos}" + ) diff --git a/tests/unittest/test_tool_output_reserve_forwarding.py b/tests/unittest/test_tool_output_reserve_forwarding.py new file mode 100644 index 0000000000..53d96d769f --- /dev/null +++ b/tests/unittest/test_tool_output_reserve_forwarding.py @@ -0,0 +1,592 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import pr_agent.tools.pr_add_docs as add_docs_module +import pr_agent.tools.pr_generate_labels as generate_labels_module +import pr_agent.tools.pr_questions as questions_module +import pr_agent.tools.pr_update_changelog as update_changelog_module +from pr_agent.algo.pr_processing import retry_with_fallback_models +from pr_agent.config_loader import get_settings +from tests.unittest._settings_helpers import restore_settings, snapshot_settings + + +@pytest.mark.parametrize( + "tool_class, tool_module, attributes, diff_options", + [ + ( + add_docs_module.PRAddDocs, + add_docs_module, + {}, + {"add_line_numbers_to_hunks": True, "disable_extra_lines": False}, + ), + (generate_labels_module.PRGenerateLabels, generate_labels_module, {"pr_id": "repo#1"}, {}), + (questions_module.PRQuestions, questions_module, {}, {}), + (update_changelog_module.PRUpdateChangelog, update_changelog_module, {}, {}), + ], + ids=["add-docs", "generate-labels", "questions", "update-changelog"], +) +@pytest.mark.asyncio +async def test_prepare_prediction_forwards_attempt_output_reserve( + monkeypatch, + tool_class, + tool_module, + attributes, + diff_options, +): + def output_token_reserve(model, default): + assert model == "fallback-model" + assert default == 1_500 + return 5_000 + + tool = tool_class.__new__(tool_class) + tool.git_provider = SimpleNamespace(pr=None, get_pr_url=lambda: "https://example.test/pr/1") + tool.token_handler = object() + tool.ai_handler = SimpleNamespace(get_output_token_reserve=output_token_reserve) + tool.vars = {"diff": "", "conversation_history": ""} + prediction = "labels: []\n" if tool_class is generate_labels_module.PRGenerateLabels else "prediction" + tool._get_prediction = AsyncMock(return_value=prediction) + for name, value in attributes.items(): + setattr(tool, name, value) + + get_pr_diff = MagicMock(return_value="diff") + monkeypatch.setattr(tool_module, "get_pr_diff", get_pr_diff) + if tool_class is generate_labels_module.PRGenerateLabels: + monkeypatch.setattr(tool_module, "set_custom_labels", lambda *_args: None) + + attempt_handler = object() + + class FakeBudget: + token_handler = attempt_handler + + def require_input_capacity(self, *_args, **_kwargs): + return 1 + + def fit_prompt_variable(self, _variables, _name, optional_text, **_kwargs): + return SimpleNamespace( + optional_text=optional_text, + system_prompt="system", + user_prompt="user", + ) + + monkeypatch.setattr( + tool_module.AttemptTokenBudget, + "for_prompt_attempt", + lambda *_args, **_kwargs: FakeBudget(), + ) + + await tool._prepare_prediction("fallback-model") + + get_pr_diff.assert_called_once_with( + tool.git_provider, + attempt_handler, + "fallback-model", + output_token_reserve=output_token_reserve, + **diff_options, + ) + forwarded_reserve = get_pr_diff.call_args.kwargs["output_token_reserve"] + assert forwarded_reserve("fallback-model", 1_500) == 5_000 + tool._get_prediction.assert_awaited_once_with("fallback-model") + assert tool.patches_diff == "diff" + assert tool.prediction == prediction + + +@pytest.mark.parametrize( + "tool_class, tool_module, attributes", + [ + (add_docs_module.PRAddDocs, add_docs_module, {}), + (generate_labels_module.PRGenerateLabels, generate_labels_module, {"pr_id": "repo#1"}), + (questions_module.PRQuestions, questions_module, {}), + (update_changelog_module.PRUpdateChangelog, update_changelog_module, {}), + ], + ids=["add-docs", "generate-labels", "questions", "update-changelog"], +) +@pytest.mark.asyncio +async def test_prepare_prediction_rejects_clipped_packed_diff( + monkeypatch, + tool_class, + tool_module, + attributes, +): + tool = tool_class.__new__(tool_class) + tool.git_provider = SimpleNamespace( + pr=None, + get_pr_url=MagicMock(return_value="https://example.test/pr/1"), + ) + tool.ai_handler = SimpleNamespace() + tool.vars = {"diff": "", "conversation_history": "", "pr_link": ""} + tool._get_prediction = AsyncMock() + for name, value in attributes.items(): + setattr(tool, name, value) + + class ClippingBudget: + token_handler = object() + + def require_input_capacity(self, *_args, **_kwargs): + return 1 + + def fit_prompt_variable(self, _variables, _name, optional_text, **_kwargs): + return SimpleNamespace( + optional_text=optional_text[:-1], + system_prompt="system", + user_prompt="user", + ) + + monkeypatch.setattr( + tool_module.AttemptTokenBudget, + "for_prompt_attempt", + lambda *_args, **_kwargs: ClippingBudget(), + ) + monkeypatch.setattr(tool_module, "get_pr_diff", lambda *_args, **_kwargs: "complete-diff") + if tool_class is generate_labels_module.PRGenerateLabels: + monkeypatch.setattr(tool_module, "set_custom_labels", lambda *_args: None) + + with pytest.raises(ValueError, match="complete packed .* diff"): + await tool._prepare_prediction("fallback-model") + + tool._get_prediction.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_add_docs_does_not_call_model_when_no_diff_fits(monkeypatch): + tool = add_docs_module.PRAddDocs.__new__(add_docs_module.PRAddDocs) + tool.git_provider = SimpleNamespace(pr=None) + tool.ai_handler = SimpleNamespace() + tool.vars = {"diff": ""} + tool._get_prediction = AsyncMock() + budget = SimpleNamespace(token_handler=object()) + + monkeypatch.setattr( + add_docs_module.AttemptTokenBudget, + "for_prompt_attempt", + lambda *_args, **_kwargs: budget, + ) + monkeypatch.setattr(add_docs_module, "get_pr_diff", lambda *_args, **_kwargs: "") + + with pytest.raises(ValueError, match="No PR diff fits"): + await tool._prepare_prediction("fallback-model") + + tool._get_prediction.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_add_docs_removes_temporary_comment_after_terminal_failure(monkeypatch): + class Config: + publish_output = True + + @staticmethod + def get(_key, default=None): + return default + + tool = add_docs_module.PRAddDocs.__new__(add_docs_module.PRAddDocs) + tool.git_provider = MagicMock() + tool._prepare_prediction = AsyncMock() + monkeypatch.setattr( + add_docs_module, + "get_settings", + lambda: SimpleNamespace(config=Config()), + ) + monkeypatch.setattr( + add_docs_module, + "retry_with_fallback_models", + AsyncMock(side_effect=RuntimeError("all attempts failed")), + ) + + await tool.run() + + tool.git_provider.publish_comment.assert_called_once_with( + "Generating Documentation...", + is_temporary=True, + ) + tool.git_provider.remove_initial_comment.assert_called_once_with() + + +@pytest.mark.parametrize( + "tool_class, tool_module, attributes, expected_comment", + [ + ( + questions_module.PRQuestions, + questions_module, + { + "pr_url": "https://example.test/pr/1", + "identify_image_in_comment": MagicMock(return_value=None), + }, + "Preparing answer...", + ), + ( + update_changelog_module.PRUpdateChangelog, + update_changelog_module, + {"push_skipped_reason": None}, + "Preparing changelog updates...", + ), + ], + ids=["questions", "update-changelog"], +) +@pytest.mark.asyncio +async def test_prompt_tool_removes_temporary_comment_after_terminal_failure( + monkeypatch, + tool_class, + tool_module, + attributes, + expected_comment, +): + class AttrDict(dict): + __getattr__ = dict.__getitem__ + + settings = SimpleNamespace( + config=AttrDict(publish_output=True), + pr_questions=AttrDict(), + get=lambda *_args, **_kwargs: {}, + ) + tool = tool_class.__new__(tool_class) + tool.git_provider = MagicMock() + tool._prepare_prediction = AsyncMock() + for name, value in attributes.items(): + setattr(tool, name, value) + + monkeypatch.setattr(tool_module, "get_settings", lambda: settings) + monkeypatch.setattr( + tool_module, + "retry_with_fallback_models", + AsyncMock(side_effect=RuntimeError("all attempts failed")), + ) + + with pytest.raises(RuntimeError, match="all attempts failed"): + await tool.run() + + tool.git_provider.publish_comment.assert_called_once_with( + expected_comment, + is_temporary=True, + ) + tool.git_provider.remove_initial_comment.assert_called_once_with() + + +@pytest.mark.parametrize( + "tool_class, tool_module, command", + [ + (questions_module.PRQuestions, questions_module, "/ask"), + ( + update_changelog_module.PRUpdateChangelog, + update_changelog_module, + "/update_changelog", + ), + ], +) +@pytest.mark.asyncio +async def test_empty_attempt_diff_retries_instead_of_succeeding( + monkeypatch, + tool_class, + tool_module, + command, +): + tool = tool_class.__new__(tool_class) + tool.git_provider = SimpleNamespace( + pr=None, + get_pr_url=MagicMock(return_value="https://example.test/pr/1"), + ) + tool.ai_handler = SimpleNamespace() + tool.vars = {"diff": "", "conversation_history": "", "pr_link": ""} + tool._get_prediction = AsyncMock() + + class FakeBudget: + token_handler = object() + + def require_input_capacity(self, *_args, **_kwargs): + return 1 + + def fit_prompt_variable(self, _variables, _name, optional_text, **_kwargs): + return SimpleNamespace( + optional_text=optional_text, + system_prompt="system", + user_prompt="user", + ) + + monkeypatch.setattr( + tool_module.AttemptTokenBudget, + "for_prompt_attempt", + lambda *_args, **_kwargs: FakeBudget(), + ) + monkeypatch.setattr(tool_module, "get_pr_diff", lambda *_args, **_kwargs: "") + + with pytest.raises(ValueError, match=f"No PR diff fits the {command} request"): + await tool._prepare_prediction("small-model") + + tool._get_prediction.assert_not_awaited() + + +@pytest.mark.parametrize( + "tool_class, tool_module", + [ + (questions_module.PRQuestions, questions_module), + (update_changelog_module.PRUpdateChangelog, update_changelog_module), + ], +) +@pytest.mark.asyncio +async def test_empty_attempt_diff_advances_to_fallback_model( + monkeypatch, + tool_class, + tool_module, +): + settings_snapshot = snapshot_settings( + ( + "config.model", + "config.fallback_models", + "openai.deployment_id", + "openai.fallback_deployments", + ) + ) + get_settings().set("config.model", "small-model") + get_settings().set("config.fallback_models", ["large-model"]) + get_settings().set("openai.deployment_id", "primary") + get_settings().set("openai.fallback_deployments", ["fallback"]) + + tool = tool_class.__new__(tool_class) + tool.git_provider = SimpleNamespace( + pr=None, + get_pr_url=MagicMock(return_value="https://example.test/pr/1"), + ) + tool.ai_handler = SimpleNamespace() + tool.vars = {"diff": "", "conversation_history": "", "pr_link": ""} + tool._get_prediction = AsyncMock(return_value="fallback prediction") + diff_models = [] + + class FakeBudget: + token_handler = object() + + def require_input_capacity(self, *_args, **_kwargs): + return 1 + + def fit_prompt_variable(self, _variables, _name, optional_text, **_kwargs): + return SimpleNamespace( + optional_text=optional_text, + system_prompt="system", + user_prompt="user", + ) + + def get_diff(_provider, _handler, model, **_kwargs): + diff_models.append(model) + return "" if model == "small-model" else "diff" + + monkeypatch.setattr( + tool_module.AttemptTokenBudget, + "for_prompt_attempt", + lambda *_args, **_kwargs: FakeBudget(), + ) + monkeypatch.setattr(tool_module, "get_pr_diff", get_diff) + + try: + await retry_with_fallback_models(tool._prepare_prediction) + finally: + restore_settings(settings_snapshot) + + assert diff_models == ["small-model", "large-model"] + tool._get_prediction.assert_awaited_once_with("large-model") + assert tool.prediction == "fallback prediction" + + +@pytest.mark.asyncio +async def test_generate_labels_counts_custom_schema_before_rejecting_empty_diff(monkeypatch): + captured_variables = [] + tool = generate_labels_module.PRGenerateLabels.__new__(generate_labels_module.PRGenerateLabels) + tool.git_provider = SimpleNamespace(pr=None) + tool.ai_handler = SimpleNamespace() + tool.vars = {"diff": "", "custom_labels_class": ""} + tool.pr_id = "repo#1" + tool._get_prediction = AsyncMock(return_value="labels: []") + + class FakeBudget: + token_handler = object() + + def require_input_capacity(self, *_args, **_kwargs): + return 1 + + def fit_prompt_variable(self, _variables, _name, optional_text, **_kwargs): + return SimpleNamespace( + optional_text=optional_text, + system_prompt="system", + user_prompt="user", + ) + + def make_budget(_model, _pr, variables, *_args, **_kwargs): + captured_variables.append(variables.copy()) + return FakeBudget() + + def set_custom_labels(variables, _provider): + variables["custom_labels_class"] = "Bug | Feature" + + monkeypatch.setattr(generate_labels_module, "set_custom_labels", set_custom_labels) + monkeypatch.setattr( + generate_labels_module.AttemptTokenBudget, + "for_prompt_attempt", + make_budget, + ) + monkeypatch.setattr(generate_labels_module, "get_pr_diff", lambda *_args, **_kwargs: "") + + with pytest.raises(ValueError, match="No PR diff fits the /generate_labels request"): + await tool._prepare_prediction("fallback-model") + + assert captured_variables == [{"diff": "", "custom_labels_class": "Bug | Feature"}] + tool._get_prediction.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_changelog_counts_pr_link_before_packing(monkeypatch): + captured_variables = [] + settings_snapshot = snapshot_settings(("pr_update_changelog.add_pr_link",)) + tool = update_changelog_module.PRUpdateChangelog.__new__( + update_changelog_module.PRUpdateChangelog + ) + tool.git_provider = SimpleNamespace( + pr=None, + get_pr_url=MagicMock(return_value="https://example.test/pr/1"), + ) + tool.ai_handler = SimpleNamespace() + tool.vars = {"diff": "", "pr_link": ""} + tool._get_prediction = AsyncMock(return_value="prediction") + + class FakeBudget: + token_handler = object() + + def require_input_capacity(self, *_args, **_kwargs): + return 1 + + def fit_prompt_variable(self, _variables, _name, optional_text, **_kwargs): + return SimpleNamespace( + optional_text=optional_text, + system_prompt="system", + user_prompt="user", + ) + + def make_budget(_model, _pr, variables, *_args, **_kwargs): + captured_variables.append(variables.copy()) + return FakeBudget() + + monkeypatch.setattr( + update_changelog_module.AttemptTokenBudget, + "for_prompt_attempt", + make_budget, + ) + monkeypatch.setattr(update_changelog_module, "get_pr_diff", lambda *_args, **_kwargs: "diff") + + try: + get_settings().set("pr_update_changelog.add_pr_link", True) + await tool._prepare_prediction("fallback-model") + finally: + restore_settings(settings_snapshot) + + assert captured_variables == [ + {"diff": "", "pr_link": "https://example.test/pr/1"} + ] + tool.git_provider.get_pr_url.assert_called_once_with() + tool._get_prediction.assert_awaited_once_with("fallback-model") + + +@pytest.mark.asyncio +async def test_questions_keep_recent_history_before_diff_packing(monkeypatch): + budget_variables = [] + budget_images = [] + fit_calls = [] + tool = questions_module.PRQuestions.__new__(questions_module.PRQuestions) + tool.git_provider = SimpleNamespace(pr=None) + tool.ai_handler = SimpleNamespace() + tool.vars = { + "diff": "", + "conversation_history": "old\nrecent", + "img_path": "https://example.test/image.png", + } + tool._get_prediction = AsyncMock(return_value="prediction") + + class FakeBudget: + def __init__(self, index): + self.index = index + self.token_handler = object() + + def require_input_capacity(self, *_args, **_kwargs): + return 1 + + def fit_prompt_variable(self, _variables, name, optional_text, **kwargs): + fit_calls.append( + (self.index, name, optional_text, kwargs.get("keep"), kwargs.get("image_path")) + ) + fitted_text = "recent" if name == "conversation_history" else optional_text + return SimpleNamespace( + optional_text=fitted_text, + system_prompt="system", + user_prompt="user", + ) + + def make_budget(_model, _pr, variables, *_args, **kwargs): + budget_variables.append(variables.copy()) + budget_images.append(kwargs.get("image_path")) + return FakeBudget(len(budget_variables)) + + monkeypatch.setattr( + questions_module.AttemptTokenBudget, + "for_prompt_attempt", + make_budget, + ) + monkeypatch.setattr(questions_module, "get_pr_diff", lambda *_args, **_kwargs: "diff") + + await tool._prepare_prediction("fallback-model") + + assert budget_variables == [ + { + "diff": "", + "conversation_history": "", + "img_path": "https://example.test/image.png", + }, + { + "diff": "", + "conversation_history": "recent", + "img_path": "https://example.test/image.png", + }, + ] + assert budget_images == ["https://example.test/image.png"] * 2 + assert fit_calls == [ + (1, "conversation_history", "old\nrecent", "suffix", "https://example.test/image.png"), + (2, "diff", "diff", None, "https://example.test/image.png"), + ] + assert tool.vars["conversation_history"] == "old\nrecent" + tool._get_prediction.assert_awaited_once_with("fallback-model") + + +@pytest.mark.asyncio +async def test_questions_retry_candidate_rejects_exhausted_fixed_prompt(monkeypatch): + tool = questions_module.PRQuestions.__new__(questions_module.PRQuestions) + tool.git_provider = SimpleNamespace(pr=None) + tool.ai_handler = SimpleNamespace() + tool.vars = {"diff": "", "conversation_history": ""} + tool._get_prediction = AsyncMock(return_value="prediction") + get_pr_diff = MagicMock(return_value="diff") + + class FakeBudget: + def __init__(self, model): + self.model = model + self.token_handler = object() + + def require_input_capacity(self, *_args, **_kwargs): + if self.model == "small-model": + raise ValueError("required prompt leaves no input capacity") + return 1 + + def fit_prompt_variable(self, _variables, _name, optional_text, **_kwargs): + return SimpleNamespace( + optional_text=optional_text, + system_prompt="system", + user_prompt="user", + ) + + monkeypatch.setattr( + questions_module.AttemptTokenBudget, + "for_prompt_attempt", + lambda model, *_args, **_kwargs: FakeBudget(model), + ) + monkeypatch.setattr(questions_module, "get_pr_diff", get_pr_diff) + + with pytest.raises(ValueError, match="no input capacity"): + await tool._prepare_prediction("small-model") + await tool._prepare_prediction("fallback-model") + + get_pr_diff.assert_called_once() + tool._get_prediction.assert_awaited_once_with("fallback-model") diff --git a/tests/unittest/test_try_fix_yaml.py b/tests/unittest/test_try_fix_yaml.py index 339889c63d..e3130a86a2 100644 --- a/tests/unittest/test_try_fix_yaml.py +++ b/tests/unittest/test_try_fix_yaml.py @@ -158,7 +158,12 @@ def test_no_initial_yaml(self): We can further improve the code by using the `const` keyword instead of `var` in the `src/index.ts` file. ''' - expected_output = {'code_suggestions': [{'relevant_file': 'src/index.ts\n', 'label': 'best practice\n'}, {'relevant_file': 'src/index2.ts\n', 'label': 'enhancement'}]} + expected_output = { + "code_suggestions": [ + {"relevant_file": "src/index.ts\n", "label": "best practice\n"}, + {"relevant_file": "src/index2.ts\n", "label": "enhancement"}, + ] + } assert try_fix_yaml(review_text, first_key='code_suggestions', last_key='label') == expected_output @@ -181,7 +186,12 @@ def test_with_initial_yaml(self): We can further improve the code by using the `const` keyword instead of `var` in the `src/index.ts` file. ''' - expected_output = {'code_suggestions': [{'relevant_file': 'src/index.ts\n', 'label': 'best practice\n'}, {'relevant_file': 'src/index2.ts\n', 'label': 'enhancement'}]} + expected_output = { + "code_suggestions": [ + {"relevant_file": "src/index.ts\n", "label": "best practice\n"}, + {"relevant_file": "src/index2.ts\n", "label": "enhancement"}, + ] + } assert try_fix_yaml(review_text, first_key='code_suggestions', last_key='label') == expected_output @@ -200,7 +210,12 @@ def test_with_brackets_yaml_content(self): enhancement } ''' - expected_output = {'code_suggestions': [{'relevant_file': 'src/index.ts\n', 'label': 'best practice\n'}, {'relevant_file': 'src/index2.ts\n', 'label': 'enhancement'}]} + expected_output = { + "code_suggestions": [ + {"relevant_file": "src/index.ts\n", "label": "best practice\n"}, + {"relevant_file": "src/index2.ts\n", "label": "enhancement"}, + ] + } assert try_fix_yaml(review_text, first_key='code_suggestions', last_key='label') == expected_output def test_tab_indent_yaml(self): @@ -216,7 +231,12 @@ def test_tab_indent_yaml(self): label: | enhancement ''' - expected_output = {'code_suggestions': [{'relevant_file': 'src/index.ts\n', 'label': 'best practice\n'}, {'relevant_file': 'src/index2.ts\n', 'label': 'enhancement\n'}]} + expected_output = { + "code_suggestions": [ + {"relevant_file": "src/index.ts\n", "label": "best practice\n"}, + {"relevant_file": "src/index2.ts\n", "label": "enhancement\n"}, + ] + } assert try_fix_yaml(review_text, first_key='code_suggestions', last_key='label') == expected_output @@ -410,21 +430,20 @@ def test_wrong_indentation_code_block_scalar(self): return a - b; } ''' - expected_code_block = '''\ -int sum(int a, int b) { - return a + b; -} - -int sub(int a, int b) { - return a - b; -} -''' - expected_output = {'code_suggestions': [{'relevant_file': 'a.c\n', 'existing_code': ' int sum(int a, int b) {\n return a + b;\n }\n\n int sub(int a, int b) {\n return a - b;\n }\n'}]} + expected_output = { + "code_suggestions": [{ + "relevant_file": "a.c\n", + "existing_code": ( + " int sum(int a, int b) {\n return a + b;\n }\n\n" + " int sub(int a, int b) {\n return a - b;\n }\n" + ), + }] + } assert try_fix_yaml(review_text, first_key='code_suggestions', last_key='existing_code') == expected_output def test_diff_markers_removed_within_list_item(self): """ - Ensures diff-style '-' markers nested inside list items are normalised so the YAML parses + Ensures diff-style '-' markers nested inside list items are normalized so the YAML parses into the expected structure. """ review_text = '''\ diff --git a/tests/unittest/test_unreachable_code.py b/tests/unittest/test_unreachable_code.py new file mode 100644 index 0000000000..3452ca099a --- /dev/null +++ b/tests/unittest/test_unreachable_code.py @@ -0,0 +1,132 @@ +"""Regression guard for unreachable code. + +Every function and method defined in pr_agent/ must be referenced somewhere in +pr_agent/, tests/ or docs/. Detection is deliberately conservative: entry points +that are named from outside Python (route handlers, Lambda handlers) and members +of third-party interfaces called by the library that owns them are recorded in +the allowlist with a note pointing at the caller. + +Abstract methods, properties, validators and dunders are skipped: they are +reached through the interface or the descriptor protocol rather than by name. + +Adding a definition nothing calls fails this test, so the dead-code ledger +cleared in #3182 cannot silently regrow. +""" + +import ast +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +PR_AGENT_SOURCE = ROOT / "pr_agent" +SEARCH_ROOTS = (ROOT / "pr_agent", ROOT / "tests", ROOT / "docs") +SEARCH_SUFFIXES = (".py", ".md", ".toml", ".yaml", ".yml") + +# Decorators that mark a definition as reachable from outside Python. +_ENTRY_POINT_DECORATORS = ( + "get", "post", "put", "delete", "patch", "route", "head", "options", + "on_event", "exception_handler", "middleware", "websocket", +) +# Decorators after which the name is reached through the descriptor protocol. +_INDIRECT_DECORATORS = ("property", "setter", "deleter", "validator", "abstractmethod") + +# Definitions with no in-repo reference that are nonetheless live. Each entry +# documents what calls it. +_ALLOWLIST = { + ( + "pr_agent/servers/github_lambda_webhook.py", + "lambda_handler", + ): "AWS Lambda entry point, named in docker/Dockerfile.lambda", + ( + "pr_agent/servers/gitlab_lambda_webhook.py", + "lambda_handler", + ): "AWS Lambda entry point, named in docker/Dockerfile.lambda", + ( + "pr_agent/algo/ai_handlers/cloud_auth.py", + "get_aws_security_credentials", + ): "google.auth.aws.AwsSecurityCredentialsSupplier interface method, called by google-auth", + ( + "pr_agent/algo/ai_handlers/cloud_auth.py", + "get_aws_region", + ): "google.auth.aws.AwsSecurityCredentialsSupplier interface method, called by google-auth", +} + + +def _is_skipped(node) -> bool: + name = node.name + if name.startswith("__") and name.endswith("__"): + return True + for decorator in node.decorator_list: + rendered = ast.unparse(decorator) + attribute = rendered.split("(")[0].split(".")[-1] + if any(marker in rendered for marker in _INDIRECT_DECORATORS): + return True + if "." in rendered and attribute in _ENTRY_POINT_DECORATORS: + return True + return False + + +def _definitions() -> dict[tuple[str, str], int]: + """Map (relative path, name) to the line the definition starts on.""" + found = {} + for path in sorted(PR_AGENT_SOURCE.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if _is_skipped(node): + continue + found.setdefault( + (path.relative_to(ROOT).as_posix(), node.name), node.lineno + ) + return found + + +def _corpus() -> list[str]: + # This file names every allowlisted definition, so reading it would make + # each of them look referenced. + this_file = Path(__file__).resolve() + texts = [] + for root in SEARCH_ROOTS: + if not root.exists(): + continue + for path in root.rglob("*"): + if not path.is_file() or path.suffix not in SEARCH_SUFFIXES: + continue + if path.resolve() == this_file: + continue + texts.append(path.read_text(encoding="utf-8", errors="ignore")) + return texts + + +def _is_referenced(name: str, corpus: list[str]) -> bool: + occurrence = re.compile(r"\b" + re.escape(name) + r"\b") + definition = re.compile( + r"[ \t]*(?:async[ \t]+)?(?:def|class)[ \t]+" + re.escape(name) + r"\b" + ) + for text in corpus: + for match in occurrence.finditer(text): + line_start = text.rfind("\n", 0, match.start()) + 1 + if definition.match(text, line_start): + continue + return True + return False + + +def test_every_definition_is_reachable_or_allowlisted(): + corpus = _corpus() + unreferenced = { + location + for location in _definitions() + if not _is_referenced(location[1], corpus) + } + allowlisted = set(_ALLOWLIST) + + assert unreferenced <= allowlisted, ( + "definitions in pr_agent/ have no reference in pr_agent/, tests/ or docs/; " + f"remove them or allowlist them: {sorted(unreferenced - allowlisted)}" + ) + assert allowlisted - unreferenced == set(), ( + "allowlist has stale entries (those definitions are now referenced or no " + f"longer present): {sorted(allowlisted - unreferenced)}" + ) diff --git a/tests/unittest/test_webhook_logic_core.py b/tests/unittest/test_webhook_logic_core.py index 1e67514a14..b8047b4770 100644 --- a/tests/unittest/test_webhook_logic_core.py +++ b/tests/unittest/test_webhook_logic_core.py @@ -722,6 +722,8 @@ async def reject_duplicate(key, **kwargs): async def perform_commands(*args): performed.append(args) + monkeypatch.setattr(gitea_app, "apply_repo_settings", lambda _url: None) + monkeypatch.setattr(gitea_app, "should_process_pr_logic", lambda _body: True) monkeypatch.setattr(gitea_app, "push_trigger_slot", reject_duplicate) monkeypatch.setattr(gitea_app, "_perform_commands_gitea", perform_commands) api_url = "https://gitea.example.com/org/repo/pulls/1" @@ -736,6 +738,169 @@ async def perform_commands(*args): assert slots == [(api_url, {"allow_backlog": True, "ttl": 300})] +@pytest.mark.asyncio +async def test_gitea_push_applies_repo_settings_before_effective_gate(monkeypatch): + settings = get_settings() + original_gitea = copy.deepcopy(settings.get("GITEA")) + original_is_auto_command = settings.get("CONFIG.IS_AUTO_COMMAND") + settings.set("GITEA.HANDLE_PUSH_TRIGGER", False) + settings.set("GITEA.PUSH_COMMANDS", []) + calls = [] + + def apply_repo_settings(_url): + calls.append("settings") + get_settings().set("GITEA.HANDLE_PUSH_TRIGGER", True) + get_settings().set("GITEA.PUSH_COMMANDS", ["/review"]) + + def should_process_pr_logic(_body): + calls.append("filter") + return True + + @asynccontextmanager + async def record_slot(_key, **_kwargs): + calls.append("slot") + yield True + + class Agent: + async def handle_request(self, _url, command): + calls.append(command) + + monkeypatch.setattr(gitea_app, "apply_repo_settings", apply_repo_settings) + monkeypatch.setattr(gitea_app, "should_process_pr_logic", should_process_pr_logic) + monkeypatch.setattr(gitea_app, "push_trigger_slot", record_slot) + monkeypatch.setattr(gitea_app, "prepare_command", lambda command: command) + api_url = "https://gitea.example.com/org/repo/pulls/1" + body = { + "pull_request": {"url": api_url}, + "repository": {"full_name": "org/repo"}, + } + + try: + await gitea_app.handle_pr_event(body, "pull_request", "synchronized", Agent()) + finally: + settings.set("GITEA", original_gitea) + settings.set("CONFIG.IS_AUTO_COMMAND", original_is_auto_command) + + assert calls == ["settings", "filter", "slot", "/review"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("repo_trigger", "repo_commands"), + [ + (False, ["/review"]), + (True, []), + ], +) +async def test_gitea_effective_push_config_skips_before_reserving_slot( + monkeypatch, repo_trigger, repo_commands +): + settings = get_settings() + original_gitea = copy.deepcopy(settings.get("GITEA")) + settings.set("GITEA.HANDLE_PUSH_TRIGGER", True) + settings.set("GITEA.PUSH_COMMANDS", ["/host-review"]) + calls = [] + + def apply_repo_settings(_url): + calls.append("settings") + get_settings().set("GITEA.HANDLE_PUSH_TRIGGER", repo_trigger) + get_settings().set("GITEA.PUSH_COMMANDS", list(repo_commands)) + + def should_process_pr_logic(_body): + calls.append("filter") + return True + + @asynccontextmanager + async def record_slot(_key, **_kwargs): + calls.append("slot") + yield True + + monkeypatch.setattr(gitea_app, "apply_repo_settings", apply_repo_settings) + monkeypatch.setattr(gitea_app, "should_process_pr_logic", should_process_pr_logic) + monkeypatch.setattr(gitea_app, "push_trigger_slot", record_slot) + api_url = "https://gitea.example.com/org/repo/pulls/1" + + try: + await gitea_app.handle_pr_event( + {"pull_request": {"url": api_url}}, "pull_request", "synchronized", RecordingAgent() + ) + finally: + settings.set("GITEA", original_gitea) + + assert calls == ["settings", "filter"] + + +@pytest.mark.asyncio +async def test_gitea_repo_filter_rejects_push_before_reserving_slot(monkeypatch): + settings = get_settings() + original_gitea = copy.deepcopy(settings.get("GITEA")) + settings.set("GITEA.HANDLE_PUSH_TRIGGER", True) + settings.set("GITEA.PUSH_COMMANDS", ["/review"]) + calls = [] + + def apply_repo_settings(_url): + calls.append("settings") + + def should_process_pr_logic(_body): + calls.append("filter") + return False + + @asynccontextmanager + async def record_slot(_key, **_kwargs): + calls.append("slot") + yield True + + monkeypatch.setattr(gitea_app, "apply_repo_settings", apply_repo_settings) + monkeypatch.setattr(gitea_app, "should_process_pr_logic", should_process_pr_logic) + monkeypatch.setattr(gitea_app, "push_trigger_slot", record_slot) + api_url = "https://gitea.example.com/org/repo/pulls/1" + + try: + await gitea_app.handle_pr_event( + {"pull_request": {"url": api_url}}, "pull_request", "synchronized", RecordingAgent() + ) + finally: + settings.set("GITEA", original_gitea) + + assert calls == ["settings", "filter"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["opened", "reopened"]) +async def test_gitea_pr_event_applies_repo_settings_once_before_dispatch(monkeypatch, action): + settings = get_settings() + original_gitea = copy.deepcopy(settings.get("GITEA")) + original_is_auto_command = settings.get("CONFIG.IS_AUTO_COMMAND") + calls = [] + + def apply_repo_settings(_url): + calls.append("settings") + get_settings().set("GITEA.PR_COMMANDS", ["/review"]) + + def should_process_pr_logic(_body): + calls.append("filter") + return True + + class Agent: + async def handle_request(self, _url, command): + calls.append(command) + + monkeypatch.setattr(gitea_app, "apply_repo_settings", apply_repo_settings) + monkeypatch.setattr(gitea_app, "should_process_pr_logic", should_process_pr_logic) + monkeypatch.setattr(gitea_app, "prepare_command", lambda command: command) + api_url = "https://gitea.example.com/org/repo/pulls/1" + + try: + await gitea_app.handle_pr_event( + {"pull_request": {"url": api_url}}, "pull_request", action, Agent() + ) + finally: + settings.set("GITEA", original_gitea) + settings.set("CONFIG.IS_AUTO_COMMAND", original_is_auto_command) + + assert calls == ["settings", "filter", "/review"] + + @pytest.mark.asyncio @pytest.mark.parametrize( ("event", "draft", "feedback_on_draft_pr", "expected_commands"), @@ -1079,3 +1244,200 @@ async def test_gitlab_push_only_update_still_takes_the_push_branch( handle_push_trigger=True, ) assert commands == [["/describe"]] + + +def test_shared_should_process_pr_logic_rules(): + from pr_agent.servers.utils import should_process_pr_logic + + settings = get_settings() + original = { + "ignore_repositories": settings.get("CONFIG.IGNORE_REPOSITORIES", []), + "ignore_pr_authors": settings.get("CONFIG.IGNORE_PR_AUTHORS", []), + "ignore_pr_title": settings.get("CONFIG.IGNORE_PR_TITLE", []), + "ignore_pr_labels": settings.get("CONFIG.IGNORE_PR_LABELS", []), + "ignore_pr_source_branches": settings.get("CONFIG.IGNORE_PR_SOURCE_BRANCHES", []), + "ignore_pr_target_branches": settings.get("CONFIG.IGNORE_PR_TARGET_BRANCHES", []), + } + try: + # Verify the default permits processing + settings.set("CONFIG.IGNORE_REPOSITORIES", []) + settings.set("CONFIG.IGNORE_PR_AUTHORS", []) + settings.set("CONFIG.IGNORE_PR_TITLE", []) + settings.set("CONFIG.IGNORE_PR_LABELS", []) + settings.set("CONFIG.IGNORE_PR_SOURCE_BRANCHES", []) + settings.set("CONFIG.IGNORE_PR_TARGET_BRANCHES", []) + + assert should_process_pr_logic( + title="feat: add feature", + sender="alice", + repo_full_name="org/repo", + labels=["enhancement"], + source_branch="feat/add-feature", + target_branch="main", + ) is True + + # Verify repository ignore rules + settings.set("CONFIG.IGNORE_REPOSITORIES", ["^org/ignored-.*$"]) + assert should_process_pr_logic(repo_full_name="org/ignored-repo") is False + assert should_process_pr_logic(repo_full_name="org/allowed-repo") is True + settings.set("CONFIG.IGNORE_REPOSITORIES", []) + + # Verify author ignore rules + settings.set("CONFIG.IGNORE_PR_AUTHORS", ["^bot-.*$", "renovate"]) + assert should_process_pr_logic(sender="bot-service") is False + assert should_process_pr_logic(sender="renovate") is False + assert should_process_pr_logic(sender="alice") is True + settings.set("CONFIG.IGNORE_PR_AUTHORS", []) + + # Verify title ignore rules for both string and list formats + settings.set("CONFIG.IGNORE_PR_TITLE", "^WIP:") # as single string + assert should_process_pr_logic(title="WIP: something") is False + assert should_process_pr_logic(title="feat: done") is True + settings.set("CONFIG.IGNORE_PR_TITLE", ["^WIP:", "^Draft:"]) # as list + assert should_process_pr_logic(title="Draft: something") is False + assert should_process_pr_logic(title="feat: done") is True + settings.set("CONFIG.IGNORE_PR_TITLE", []) + + # Verify label ignore rules + settings.set("CONFIG.IGNORE_PR_LABELS", ["skip-review", "do-not-merge"]) + assert should_process_pr_logic(labels=["skip-review", "bug"]) is False + assert should_process_pr_logic(labels=["bug"]) is True + settings.set("CONFIG.IGNORE_PR_LABELS", []) + + # Verify source and target branch ignore rules + settings.set("CONFIG.IGNORE_PR_SOURCE_BRANCHES", ["^release/.*$"]) + settings.set("CONFIG.IGNORE_PR_TARGET_BRANCHES", ["^production$"]) + assert should_process_pr_logic(source_branch="release/1.0", target_branch="main") is False + assert should_process_pr_logic(source_branch="feature/test", target_branch="production") is False + assert should_process_pr_logic(source_branch="feature/test", target_branch="main") is True + finally: + for k, v in original.items(): + settings.set(f"CONFIG.{k.upper()}", v) + + +def test_shared_should_process_pr_logic_payload_parsing(): + from pr_agent.servers.utils import should_process_pr_logic + + settings = get_settings() + original_repos = settings.get("CONFIG.IGNORE_REPOSITORIES", []) + settings.set("CONFIG.IGNORE_REPOSITORIES", ["^ignore-org/.*$"]) + try: + # Verify GitHub payload parsing + gh_payload = { + "pull_request": {"title": "GH PR", "head": {"ref": "feat"}, "base": {"ref": "main"}}, + "repository": {"full_name": "ignore-org/repo"}, + "sender": {"login": "gh-user"}, + } + assert should_process_pr_logic(gh_payload) is False + + # Verify GitLab payload parsing + gl_payload = { + "object_attributes": {"title": "GL MR", "source_branch": "feat", "target_branch": "main"}, + "project": {"path_with_namespace": "ignore-org/repo"}, + "user": {"username": "gl-user"}, + } + assert should_process_pr_logic(gl_payload) is False + + # Verify a GitLab non-merge payload is rejected + assert should_process_pr_logic({}, provider="gitlab") is False + + # Verify Bitbucket Cloud payload parsing + bb_cloud_payload = { + "data": { + "pullrequest": { + "title": "BB PR", + "destination": {"repository": {"full_name": "ignore-org/repo"}}, + } + } + } + assert should_process_pr_logic(bb_cloud_payload) is False + + # Verify Bitbucket Server payload parsing + bb_server_payload = { + "pullRequest": { + "title": "BBS PR", + "toRef": {"repository": {"project": {"key": "ignore-org"}, "slug": "repo"}}, + } + } + assert should_process_pr_logic(bb_server_payload) is False + finally: + settings.set("CONFIG.IGNORE_REPOSITORIES", original_repos) + + +def test_shared_should_process_pr_logic_explicit_empty_overrides_payload(): + """Verify that explicitly passing empty metadata overrides nonempty payload values.""" + from pr_agent.servers.utils import should_process_pr_logic + + settings = get_settings() + original = { + "ignore_repositories": settings.get("CONFIG.IGNORE_REPOSITORIES", []), + "ignore_pr_authors": settings.get("CONFIG.IGNORE_PR_AUTHORS", []), + "ignore_pr_title": settings.get("CONFIG.IGNORE_PR_TITLE", []), + "ignore_pr_labels": settings.get("CONFIG.IGNORE_PR_LABELS", []), + "ignore_pr_source_branches": settings.get("CONFIG.IGNORE_PR_SOURCE_BRANCHES", []), + "ignore_pr_target_branches": settings.get("CONFIG.IGNORE_PR_TARGET_BRANCHES", []), + } + settings.set("CONFIG.IGNORE_REPOSITORIES", ["^ignore-org/.*$"]) + settings.set("CONFIG.IGNORE_PR_AUTHORS", ["^ignore-bot$"]) + settings.set("CONFIG.IGNORE_PR_TITLE", ["\\[WIP\\].*"]) + settings.set("CONFIG.IGNORE_PR_LABELS", ["do-not-merge"]) + settings.set("CONFIG.IGNORE_PR_SOURCE_BRANCHES", ["^ignore-source$"]) + settings.set("CONFIG.IGNORE_PR_TARGET_BRANCHES", ["^ignore-target$"]) + + try: + # Construct a payload that matches all ignore criteria + payload = { + "pull_request": { + "title": "[WIP] Feature", + "head": {"ref": "ignore-source"}, + "base": {"ref": "ignore-target"}, + "labels": [{"name": "do-not-merge"}], + }, + "repository": {"full_name": "ignore-org/repo"}, + "sender": {"login": "ignore-bot"}, + } + # Verify the raw payload is rejected by ignore filters + assert should_process_pr_logic(payload) is False + + # Verify overriding ignored fields with empty values permits processing + assert ( + should_process_pr_logic( + payload, + repo_full_name="", + sender="", + title="", + labels=[], + source_branch="", + target_branch="", + ) + is True + ) + finally: + for k, v in original.items(): + settings.set(f"CONFIG.{k.upper()}", v) + + +def test_bitbucket_server_should_process_pr_logic_fails_open_on_filter_exception(monkeypatch): + """Verify a malformed filter regex fails open without reaching the folder filter.""" + settings = get_settings() + original_title = settings.get("CONFIG.IGNORE_PR_TITLE", []) + original_folders = settings.config.get("allow_only_specific_folders", []) + settings.set("CONFIG.IGNORE_PR_TITLE", ["[unclosed"]) + settings.set("CONFIG.ALLOW_ONLY_SPECIFIC_FOLDERS", ["allowed_dir"]) + + class _RejectingProvider: + def __init__(self, pr_url=None): + pass + + def get_files(self): + return ["docs/readme.md"] + + monkeypatch.setattr( + "pr_agent.git_providers.bitbucket_server_provider.BitbucketServerProvider", _RejectingProvider + ) + + try: + assert bitbucket_server_webhook.should_process_pr_logic(_bitbucket_server_payload(title="Test PR")) is True + finally: + settings.set("CONFIG.IGNORE_PR_TITLE", original_title) + settings.set("CONFIG.ALLOW_ONLY_SPECIFIC_FOLDERS", original_folders) diff --git a/uv.lock b/uv.lock index c48de07cc6..8f8b8a9662 100644 --- a/uv.lock +++ b/uv.lock @@ -214,15 +214,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.1" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -330,11 +330,11 @@ wheels = [ [[package]] name = "certifi" -version = "2024.8.30" +version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507, upload-time = "2024-08-30T01:55:04.365Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321, upload-time = "2024-08-30T01:55:02.591Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] @@ -405,63 +405,63 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/56/10a88e00039537d74bd420f0457c52ab8f58a1af56126e3b9f1b1c8c4724/charset_normalizer-3.4.8.tar.gz", hash = "sha256:d9bf144d6faf12c70d58e47f7512992ae2882b820031d6cef68152deb645bf2d", size = 151790, upload-time = "2026-07-06T15:27:58.477Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/c2/39de60ef5687662f467bed3d1e6944c67a4f0d057141d0404002b8f405ae/charset_normalizer-3.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:faac37c4904598daa00cb4c9b32f3b4cc814fb5f145d7a531ceb4a70f2114132", size = 319040, upload-time = "2026-07-06T15:26:15.854Z" }, - { url = "https://files.pythonhosted.org/packages/a7/57/a9474c3aeaa337c8a330c0dc5df266527d56da3b189c029529f6b08af2a4/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f191c19a32dc6cec0fb8079789d786254653a9ce906fcab04ccd2eed07bba233", size = 215541, upload-time = "2026-07-06T15:26:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/13/a9/be1ff7e81f6e086dced2a7a7a28b789be351d9796084ccaf6136a4ffafb3/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05811b76943d477bb90822dedb5c4565cef70148847a59d574e2b35043aeb563", size = 236913, upload-time = "2026-07-06T15:26:18.482Z" }, - { url = "https://files.pythonhosted.org/packages/8f/75/d8c5eae93da26d463f9ebe46a4937ca44434dc2937a565b92437befb3d94/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3868a3e4ec1e40b419e060d063f93eac6f046fa21426c4816421223ae7dc8ab8", size = 232815, upload-time = "2026-07-06T15:26:19.734Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/98e301ca944bcca5e6bc312406b579c8a6d81546c1b494afb3a9478495d6/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25f93d194eb6264c64416cabff46a91f6d99b97e7525a1b4f35c77a99e75cc68", size = 223995, upload-time = "2026-07-06T15:26:20.931Z" }, - { url = "https://files.pythonhosted.org/packages/fa/df/f5222366b76dcb31453a9bd922610c893540d0e729fd390439b0d3e972ee/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:ee6a62492f18d432cca031fabd158f400a8c25bf7b9458f50953393a2a23d97a", size = 208522, upload-time = "2026-07-06T15:26:22.214Z" }, - { url = "https://files.pythonhosted.org/packages/74/52/293220d59d8ddfb8aa56836b33bd6df58e70795d8a102a858c2984480f00/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c16cb4fc35e4b064f5ee78d849f15a550ada1729c3372916672e38f1f01d1d4", size = 219660, upload-time = "2026-07-06T15:26:23.493Z" }, - { url = "https://files.pythonhosted.org/packages/c3/2c/81a298e66f3d01e61bfc6f7064bbb553b067a9f1d979e5962bf00733069b/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2fbd0edb0426ab28e70fac9d1d4ef549eb5a64a2521f0428c441d75e4387e6c2", size = 218230, upload-time = "2026-07-06T15:26:24.629Z" }, - { url = "https://files.pythonhosted.org/packages/0d/45/f1dd2328cbc3340705f82072c09bd4c68d6e079191cde05810c1eac77eee/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccb9052771216170015f810b88065fd9e13b1e0b391f92abb9b47e0919a42aad", size = 210006, upload-time = "2026-07-06T15:26:25.837Z" }, - { url = "https://files.pythonhosted.org/packages/38/63/28697000620e117eb413424caaf60b6f98ddb1b09b2c11f7c0038d9936a7/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3809ba5d3cd02aca0894597f2669a825bdfe2229061515c128b0f4e5533b4ab5", size = 225771, upload-time = "2026-07-06T15:26:27.079Z" }, - { url = "https://files.pythonhosted.org/packages/12/a7/d5844e315f5f35e7938c415f07a1df144eed1cf993f1b43cc16c980c5b46/charset_normalizer-3.4.8-cp312-cp312-win32.whl", hash = "sha256:de63c31666a049f653ada24e800192e3c019e96bc7d70fb449a000bccf26a36f", size = 150922, upload-time = "2026-07-06T15:26:28.514Z" }, - { url = "https://files.pythonhosted.org/packages/9b/82/eb8b72f184b1e4986dd9daec15d7f6d9285a6728d2b07b7f04656829f473/charset_normalizer-3.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:14a4bbe066f3fb05c6ba70e9cf9d34614b57a2fd70ea8c27cc30f34155e16a58", size = 162294, upload-time = "2026-07-06T15:26:29.745Z" }, - { url = "https://files.pythonhosted.org/packages/09/5a/ab810134aa41034a08ffe94c058102016e6ad9bce62f3cdba547b4723385/charset_normalizer-3.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:2b5b0c0dca0a02c3f816f89abf18af3d20416dedbc3d3aa5f3981045f88ae7b0", size = 152409, upload-time = "2026-07-06T15:26:31.034Z" }, - { url = "https://files.pythonhosted.org/packages/11/49/fe5a8572a70cd9cba79f80af9388ac8c5c914ed4459b956f940244e499a5/charset_normalizer-3.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:057f8609f7341618c98e5aa9a6109fa116acff2a658497d47ab3325b5e8f2b08", size = 317424, upload-time = "2026-07-06T15:26:32.23Z" }, - { url = "https://files.pythonhosted.org/packages/e5/59/d71c96616b6825425a876f79f38fa440db30b32cc1166179a839f6259150/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c591f9a82adc5b89a039b90df74e43de2b9177fb46771172bed7b80722a70db0", size = 214723, upload-time = "2026-07-06T15:26:33.635Z" }, - { url = "https://files.pythonhosted.org/packages/c6/35/dc9eeb297f19b7b6ada39709ccb74937e6c51f0947958ae601a977cedd5d/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b56f449132d9adefe55b87635d05177a914ed5d070438a74725e1d77a280002", size = 236200, upload-time = "2026-07-06T15:26:34.99Z" }, - { url = "https://files.pythonhosted.org/packages/0a/37/6775fe852b4acad8bf7e0575fbe8aa9f41b546e33251acbded3c04a6b0d9/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1004c3b5831a301dadfb9e916f38e78e2ff3e08db24a1ad7c354db8ee3dea9c3", size = 231740, upload-time = "2026-07-06T15:26:36.498Z" }, - { url = "https://files.pythonhosted.org/packages/e4/28/1bcc3f5f3bac81532384adcfcdd9362c7f46a188a19deacc1ddaf7bdaa00/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60883e22821d17c9e5b4f3ca1ef8074f766e3db28791f851b665929c515635c0", size = 222888, upload-time = "2026-07-06T15:26:38.161Z" }, - { url = "https://files.pythonhosted.org/packages/47/81/9f3993ca62ef090c58059da641e49e3129e74700a6a3beb58436cdb8d4b9/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:21e4dbb942c8a6342e2685f232dd2a7bc73465697bd26ead4f118271d28be383", size = 207649, upload-time = "2026-07-06T15:26:39.628Z" }, - { url = "https://files.pythonhosted.org/packages/11/f4/679f636bcbdc2d53d06b1f4039be310450dca95a9f76bbf22f09985556e8/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84bcae14c65e645ca66b661339183d32b8c846a17c96e3e81ab3d346e1c498d4", size = 218908, upload-time = "2026-07-06T15:26:40.911Z" }, - { url = "https://files.pythonhosted.org/packages/7e/44/96e8c81867ba8a45ff893c8e7474c2d6b9633f7aa663da7901d040214d3e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b4e4d44b8287aa13a25e16e29393d494b0643b24894f7c8266c6f6788dd36337", size = 217096, upload-time = "2026-07-06T15:26:42.148Z" }, - { url = "https://files.pythonhosted.org/packages/a5/bf/4d53f04f29bdb22601701f4f9f4d038edfb27976c296fcb7400c02736a6e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:f68545d1b267dbfafd5d253b6d1cb161562c4e61ab25b5c4cdb7d9e5923e441e", size = 209355, upload-time = "2026-07-06T15:26:43.557Z" }, - { url = "https://files.pythonhosted.org/packages/ad/60/92b3f630798d777fa880ad289a3f9f2fc663e4b4beb24783c53318820254/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1936e48214adea74922a20c8ab41b1393ae27cc9e329eb1f0b937d3416824f36", size = 224732, upload-time = "2026-07-06T15:26:44.892Z" }, - { url = "https://files.pythonhosted.org/packages/bd/85/eafa0a3c7bb6fe9f02f4c7901f02071933cac85ee634197e17280818c6de/charset_normalizer-3.4.8-cp313-cp313-win32.whl", hash = "sha256:1f8e3521860187d597f3867d8466da225b9179ea2833bb26de1bb026144d07c3", size = 150358, upload-time = "2026-07-06T15:26:46.153Z" }, - { url = "https://files.pythonhosted.org/packages/f5/8c/879fafff7b47bb1166d289f2d2472cb31b9922f9f4ca1f392edf85ec16be/charset_normalizer-3.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:8b654b6f52a0a9a6be38e88f3e1dc68f1093ebeb2abbadafc7c82da0786a34be", size = 161685, upload-time = "2026-07-06T15:26:47.423Z" }, - { url = "https://files.pythonhosted.org/packages/c3/46/b57c7e778a7b578f28d35fd38544687d4f8d9c019585eebc5ad936073fad/charset_normalizer-3.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:d2d5a250ee26e29468b7607d97479221b069fa8aaf6f929ac84ec0e962e15154", size = 152333, upload-time = "2026-07-06T15:26:48.689Z" }, - { url = "https://files.pythonhosted.org/packages/1c/bc/0a8540b8cd494951cca1428606373942803f5ffcec40fe798f819c5a8adb/charset_normalizer-3.4.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:77e993ecf65f21ab1f82266ff5e84a7de2c879e7d9b8bc006009df83f22a1d5e", size = 316993, upload-time = "2026-07-06T15:26:49.962Z" }, - { url = "https://files.pythonhosted.org/packages/0e/99/a0868f0a1f0a045fd374d1f2cf7042d8ad5d7fb4dd1f4ac7365e319f7e32/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:524939917f17f6de502dfda30b472550965740d7f126659d4c4f8dd1569cce22", size = 215638, upload-time = "2026-07-06T15:26:51.338Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e9/43c4d09a09b5557cc5fe1d87c9d96f86a3942aec0517d2b5408cef87ca75/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a4508989ba8e2ce43ef989453d18188b261546e8188cbdd4ef451fb9e4c3b467", size = 236456, upload-time = "2026-07-06T15:26:52.531Z" }, - { url = "https://files.pythonhosted.org/packages/e2/67/492ca98b3ab785b736b5da10c1bc233e1c8fec6c0cdb29b482c38bfc52a2/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9e44127f7d11eee4548ad2cdf1f4e1b6eaaddd5cb92d15ad65f6ecc9bcf403ab", size = 232253, upload-time = "2026-07-06T15:26:53.838Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fd/1e6eff58c14f1aace1e26d80defbeaea2d35e075dbe4b611111ee4b47fa8/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb90317359f7e67bb6df615999a95e0980877468e617ddce8b6c2f8e7fe60d95", size = 222886, upload-time = "2026-07-06T15:26:55.009Z" }, - { url = "https://files.pythonhosted.org/packages/40/7a/90056a5326b0c4b9a3f924d337729c344c11542e5bc7191e50410db61587/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:35d9e7a9c960520ae89d1f4e305d1c047a74dea2e0f73a0e84f879356c2e8776", size = 206482, upload-time = "2026-07-06T15:26:56.306Z" }, - { url = "https://files.pythonhosted.org/packages/18/ff/94761d31a33878dbb5008ddbd918615061fcf5c0a612aa3075450e60f628/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e322b054c7ff886f78feab7360736bb45de2e18cf4a0ee84e8fc5a08d53a19", size = 218929, upload-time = "2026-07-06T15:26:57.422Z" }, - { url = "https://files.pythonhosted.org/packages/2b/dc/00b9675acd7c4b926b9102ee3f0d1a570ce943901be73b87485001393fe1/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c0086d97094363556206dc3bcf43f7edcfc043ea7a568a46f45efea74858bd1", size = 218069, upload-time = "2026-07-06T15:26:58.719Z" }, - { url = "https://files.pythonhosted.org/packages/04/11/94ada5a0482ee4bf688d04be4c7d6fd945d37370d04a95671040dfe2b416/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0752c849b51198267df2aba013c4de3a2955bd014a4fd70828809946c1acbc0c", size = 207146, upload-time = "2026-07-06T15:27:00.058Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f7/246bd36762207ab4752cd436b64e5d81a1668b15ddea7b5b2d0e8545e727/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2a4707e09eca11e81ece4fced600c5a0a801f568b962244f6f517bc274745fc9", size = 224896, upload-time = "2026-07-06T15:27:01.599Z" }, - { url = "https://files.pythonhosted.org/packages/6f/f7/3510622d1fbe13b0ebf827c475e40a27e2be427140d792878b63ab6425cc/charset_normalizer-3.4.8-cp314-cp314-win32.whl", hash = "sha256:8ea67f427c073ae3da0923aa55f3715131fa613a61a7f2f8d762bde75eaf00ae", size = 150851, upload-time = "2026-07-06T15:27:02.964Z" }, - { url = "https://files.pythonhosted.org/packages/32/2b/9ce65dd21672b55cf800cca5f4433afa1586fda1d78731067ec9ec544c62/charset_normalizer-3.4.8-cp314-cp314-win_amd64.whl", hash = "sha256:ff71018850863362e5c7533769d0a9f77715c31af1502d523630ce822922f5c9", size = 162549, upload-time = "2026-07-06T15:27:04.249Z" }, - { url = "https://files.pythonhosted.org/packages/2f/34/9a5967eed666a88f31a0866884606d9ec3c2cd6091e2ccd7e0b4c4176c35/charset_normalizer-3.4.8-cp314-cp314-win_arm64.whl", hash = "sha256:44464e66f4da2f21dea7145c7693f9f60717ca4794a954dea5bf8c2c932678bd", size = 153079, upload-time = "2026-07-06T15:27:05.608Z" }, - { url = "https://files.pythonhosted.org/packages/02/4f/aa44cc81d8987f105352c74c0bf919007f8b80e9880d28bcf0393c1a816e/charset_normalizer-3.4.8-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:50a0c2e58ad2c203adb616fef28941b7e13716adbc25e0dfaeec29f5afe6382f", size = 338586, upload-time = "2026-07-06T15:27:06.86Z" }, - { url = "https://files.pythonhosted.org/packages/1d/2b/b0392e2b235c08ff0623d905c2ee8ac820620544043c1ce92ce0b3d64c55/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1e589fdb95c76f08288bbb346230cdd8994db74903db6637b380f7b5fc9336", size = 222764, upload-time = "2026-07-06T15:27:08.23Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a1/7d466879190731f5559662c22232646f2ae2dace2323c3e5aefcf78d458a/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3d7c887444c5a7ef0d68d358d81e758a850bc626f8e639e2ca5667153272b20", size = 241331, upload-time = "2026-07-06T15:27:09.512Z" }, - { url = "https://files.pythonhosted.org/packages/70/17/8b89e797137aa28c8fb0bafbafc243246a7afe21620a13b00e37624ece1d/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:65c389b96c0cfff3a3f0458fa1c7ce554a30e23101a88a49f03997afce6a929f", size = 239323, upload-time = "2026-07-06T15:27:10.86Z" }, - { url = "https://files.pythonhosted.org/packages/d7/98/1c1940730ed22d50983be4e243c722c89d5136d6f073bd840d1128bfddcb/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:593403fc47dcdf55e2987b2e3cc2e064127e2b908929f1f18b2e4a4652cbd780", size = 229964, upload-time = "2026-07-06T15:27:12.113Z" }, - { url = "https://files.pythonhosted.org/packages/53/2d/bb8e81b7ff603d3f77e9a8a5d1ad34fcabbf3c54d300c29d99fba581fa23/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:606088e9fa2b7469ab9c42d4da8e05a415622a07714edd2fcd8fed48dda4c853", size = 212405, upload-time = "2026-07-06T15:27:13.447Z" }, - { url = "https://files.pythonhosted.org/packages/ce/1f/e52a3a53b13da591bb8f21d29e63877268eadf20686b7762351d4b89062c/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0317406326fed512f42a1632ad91a96228a7616c06547666a6dd79967f1bd6ca", size = 226918, upload-time = "2026-07-06T15:27:14.89Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f9/32996d79c57189af9722fe618f46d8a86b7be035ca98887b8d0c3821f141/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b67d50ee47e5c57a0064a9cb575b963a7125819dfd1fd094d44d378fff94659b", size = 225113, upload-time = "2026-07-06T15:27:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d2/9248c18e695696513774523a794cfb8b677521ce9ad7554d301cb10a9b20/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79e402b869f270140afa5e2b0e2ac100585358d812fe3dd093d424f7a72964e0", size = 214966, upload-time = "2026-07-06T15:27:17.418Z" }, - { url = "https://files.pythonhosted.org/packages/1e/9d/4b19432d406179a40f924691906ee5b15ac664b408971c973295192444ea/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2970b9f7ab69ec3a0423ec6b6ac718e79fbf4a282c0bc103ef88c1ef50dfa15a", size = 231699, upload-time = "2026-07-06T15:27:19.131Z" }, - { url = "https://files.pythonhosted.org/packages/be/41/bdbdf71e8c3ccff10ef3cc2bb9467a7fdb3dc94b9a406d1a3c44afd39632/charset_normalizer-3.4.8-cp314-cp314t-win32.whl", hash = "sha256:458c2972a78043b7261c9726670029f15f722e70669bcbe961153a01968f589f", size = 155333, upload-time = "2026-07-06T15:27:20.681Z" }, - { url = "https://files.pythonhosted.org/packages/bd/f8/e05c69323bd50091ec39f5f885385b884624b0131a6885a0c83a6217ba7a/charset_normalizer-3.4.8-cp314-cp314t-win_amd64.whl", hash = "sha256:0c926329a1df7cd56d7d8349fe354460d20aefd2e394c9e159e479d018b2b359", size = 167378, upload-time = "2026-07-06T15:27:22.042Z" }, - { url = "https://files.pythonhosted.org/packages/c2/04/cbaf1a2f5e2bbf70760e774380cbf052b10849fc35e770905df31af5cf00/charset_normalizer-3.4.8-cp314-cp314t-win_arm64.whl", hash = "sha256:2232baea80a2b01783679fed4e625ccdb19a974f44c9cf0fba21a777a4c8179c", size = 157782, upload-time = "2026-07-06T15:27:23.312Z" }, - { url = "https://files.pythonhosted.org/packages/23/52/d5bee5b6ea81882d549b566d2545b044bbcbc33fe5fbe001008a7e745a21/charset_normalizer-3.4.8-py3-none-any.whl", hash = "sha256:b7c1fb310df524e01fbe84d43b7f95aa4f808f8eaa0dafc185f64ba395e37d54", size = 64279, upload-time = "2026-07-06T15:27:57.043Z" }, +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] [[package]] @@ -679,11 +679,11 @@ wheels = [ [[package]] name = "dynaconf" -version = "3.2.13" +version = "3.3.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/0e/05927cf459e73f8bf9a9277cbea6f2d5b7db8a5cc9dc1e20e7a5fbac1b90/dynaconf-3.2.13.tar.gz", hash = "sha256:d79e0189d97b3f226b8ebb1717e2ce05d1a05cdf6ea05de66d24625fdb5a0cbd", size = 283507, upload-time = "2026-03-17T19:38:47.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/e4/723ba469856bb493c948985e5bd562c8a65f2b93e70c896e9764ab76de00/dynaconf-3.3.5.tar.gz", hash = "sha256:a08f6ab44025034ef3c9f86b32548ab01efd4039094a74bd9f028a43c63d016f", size = 333426, upload-time = "2026-08-05T19:50:25.16Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/43/11d6e5d2c00bf000b5329717c74563bf76a9193f4a41cb0c4ef277dde4fa/dynaconf-3.2.13-py2.py3-none-any.whl", hash = "sha256:4305527aef4834bdba3e39479b23c005186e83fb85f65bcaa4bcea58fa26759b", size = 238041, upload-time = "2026-03-17T19:38:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/95/41/b38501c34c40c3261868a2af8ed5fffe3e6d889c680638708de27eb57788/dynaconf-3.3.5-py3-none-any.whl", hash = "sha256:b3b4a753d5c866fc85466b65fc58d4954f6ed7dc8aa67f2b15bcbfd85d184687", size = 271426, upload-time = "2026-08-05T19:50:23.802Z" }, ] [[package]] @@ -872,57 +872,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, ] -[[package]] -name = "gcsfs" -version = "2025.12.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.13' and sys_platform == 'win32'", - "python_full_version < '3.13' and sys_platform == 'emscripten'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "aiohttp" }, - { name = "decorator" }, - { name = "fsspec", version = "2025.12.0", source = { registry = "https://pypi.org/simple" } }, - { name = "google-auth" }, - { name = "google-auth-oauthlib" }, - { name = "google-cloud-storage", version = "2.10.0", source = { registry = "https://pypi.org/simple" } }, - { name = "google-cloud-storage-control" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3e/3e/453b42bbcda2177daba97ebc5202faf5c57cc0c2bb4aac7081c12b0471da/gcsfs-2025.12.0.tar.gz", hash = "sha256:9a9c1c32b7899a3967ba30a7e9422cdfda596266f03476cdf5545402b8af4cd5", size = 95148, upload-time = "2025-12-03T15:44:59.703Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/bf/e664cbeab8f2a8b097c0604252760410fde191fe6ac7d5081b29e601ac52/gcsfs-2025.12.0-py3-none-any.whl", hash = "sha256:e06aaec53797dc6b83d5cc90c4d3ae7247b4ee0cf8d8b1ce50e8d6b78e3a9aea", size = 41204, upload-time = "2025-12-03T15:44:58.464Z" }, -] - -[[package]] -name = "gcsfs" -version = "2026.7.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -dependencies = [ - { name = "aiohttp" }, - { name = "decorator" }, - { name = "fsspec", version = "2026.6.0", source = { registry = "https://pypi.org/simple" } }, - { name = "google-auth" }, - { name = "google-auth-oauthlib" }, - { name = "google-cloud-storage", version = "3.12.0", source = { registry = "https://pypi.org/simple" } }, - { name = "google-cloud-storage-control" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/33/58e23c6f492e091c2f01ae1bffe331aa97e18a0b3d24ff90ef648335ddc8/gcsfs-2026.7.0.tar.gz", hash = "sha256:2df36ce1e9010e76dc4295774030495a97496838483c619e9f63bc0ab7bdc770", size = 1265874, upload-time = "2026-07-06T15:02:30.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/eb/a5c4b39903928e6be7735cc7aa31b6c3bbf3d887419e1e7ac751dba02233/gcsfs-2026.7.0-py3-none-any.whl", hash = "sha256:8ce6c08ea64302d8fd4bc23a615ccd9e7752541cde1a88ec8362109bd79cde78", size = 91131, upload-time = "2026-07-06T15:02:29.208Z" }, -] - [[package]] name = "gitdb" version = "4.0.12" @@ -949,14 +898,14 @@ sdist = { url = "https://files.pythonhosted.org/packages/79/41/1c5643897ab6bcba3 [[package]] name = "gitpython" -version = "3.1.59" +version = "3.1.62" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/dc/126b28e76b24a9268ba931ad3e012f71ebdadf62fd9f17758f7074bb0b20/gitpython-3.1.59.tar.gz", hash = "sha256:0a1475cfdc38a5bfba1a3e9a4a9da52a39749ecec322b772915c019f94e5b7e4", size = 230445, upload-time = "2026-08-10T12:03:20.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/db/3ca813cbacb23ab6fe46ff38a9b5ef8e73e970c8051f2ce903aacafe0446/gitpython-3.1.62.tar.gz", hash = "sha256:1791de66309bc0c7cfca40bf8d2e3de7ca091cbf94e6051be1ad0722c61062af", size = 231728, upload-time = "2026-09-07T02:57:21.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl", hash = "sha256:67a82f537384578643624c8b2c531938a9b82be431663e575dcf638526631d4c", size = 220996, upload-time = "2026-08-10T12:03:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/d6/0b/29d7965215f8ef830a7ca1f42997fe13e5693d85e9edb18f938d063ef5f2/gitpython-3.1.62-py3-none-any.whl", hash = "sha256:7002251225e10e29d2e1f49e6532613fe5d5d9f0b6f1f02997a52b38fe56899e", size = 222753, upload-time = "2026-09-07T02:57:19.762Z" }, ] [[package]] @@ -1002,19 +951,6 @@ requests = [ { name = "requests" }, ] -[[package]] -name = "google-auth-oauthlib" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "requests-oauthlib" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/70/18/90c7fac516e63cf2058166fce0c88c353647c677b51cc036c09c49bb5cbb/google_auth_oauthlib-1.4.0.tar.gz", hash = "sha256:18b5e28880eb8eba9065c436becdc0ee8e4b59117a73a510679c82f70cd363d2", size = 21675, upload-time = "2026-05-07T08:03:47.816Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/d3/d7dff0d58a9e9244b48044bfb6a898bfcc8ecc42e0031d1bebc695344725/google_auth_oauthlib-1.4.0-py3-none-any.whl", hash = "sha256:251314f213a9ee46a5ae73988e84fd7cca8bb68e7ecf4bfd45940f9e7f51d070", size = 19261, upload-time = "2026-05-07T08:02:13.798Z" }, -] - [[package]] name = "google-cloud-aiplatform" version = "1.154.0" @@ -1133,23 +1069,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/bd/a89eaebd2f9db5f92ddcc8e4f23c266be1dbd11058bb83451d8dd029f34c/google_cloud_storage-3.12.0-py3-none-any.whl", hash = "sha256:3880773754ddf7c27567b04e2a4d193950b6b99429f37b9097d873686e95b09c", size = 340605, upload-time = "2026-06-12T18:03:12.677Z" }, ] -[[package]] -name = "google-cloud-storage-control" -version = "1.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/88/ae/707995271f77e3c1da715c740160f98f87a79a5c601b28d17c4d2500c037/google_cloud_storage_control-1.12.0.tar.gz", hash = "sha256:49090d03532c0c84c6246a5fd490c31fd4bbffb4c7771c0a6744ec23a194a5f6", size = 137036, upload-time = "2026-06-03T16:14:00.921Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/ba/f187f30fb2c8bb5dbb0de0fcb0dec2f8fab0b782ab42facc83ea02628f1b/google_cloud_storage_control-1.12.0-py3-none-any.whl", hash = "sha256:20f7f1252fa5635d47e12f746aa69d719e31eb9405cbbd14cdfba317db27d421", size = 102205, upload-time = "2026-06-03T16:12:43.266Z" }, -] - [[package]] name = "google-crc32c" version = "1.8.0" @@ -1239,57 +1158,57 @@ wheels = [ [[package]] name = "grpcio" -version = "1.82.0" +version = "1.83.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/19/e29d3979b420b92d516ee97f0bff4fb88cbb3d724791318f50beb55db549/grpcio-1.82.0.tar.gz", hash = "sha256:bfe3247e0bb598585ce26565730970d21bccf3c9dc547dc6703a1a3c7888e8e1", size = 13184479, upload-time = "2026-07-06T04:22:54.421Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/e6/9ce68bc431224177b6f506cfb4896a742119c51255143069970955b6510b/grpcio-1.82.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:03cce11532da292215cd8e5f444de91982e39dfb41a916e0e0f91a5c128588ea", size = 6144680, upload-time = "2026-07-06T04:21:29.808Z" }, - { url = "https://files.pythonhosted.org/packages/59/93/00ecf28e1be7a70b4b4d148d3a551b6c9a37024a669c3650a2c374c64026/grpcio-1.82.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:01336fe31d1ea5d5154b0d803eb3584e69fb96e0e657acc87bd4d48d6abc9587", size = 11952213, upload-time = "2026-07-06T04:21:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/38/05/9be6bf4cddb5b5f39c0748cdf5639525cd4116a157c8f3802c9217e54882/grpcio-1.82.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe56f1bea709ddb2531fbd510a2dd6040e993985507c3b2bf3404507aee989b1", size = 6710770, upload-time = "2026-07-06T04:21:35.382Z" }, - { url = "https://files.pythonhosted.org/packages/00/97/62c0969e993673ebef16d526db2504f38bc4c73cf2593c28746791ab7956/grpcio-1.82.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:77a5d8fe331376c7aaa4e06e903a43bd144de4f98c6e414e13cbc5d64e5aa61e", size = 7450671, upload-time = "2026-07-06T04:21:37.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/1f/d83d9fbaaef2b6ebbf70a6e467000f13f92b4cf0b84c561c162b43128439/grpcio-1.82.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ce80ea66c803398fde9c5b1fd925920c9742da7f10f8b85acac0adac3e4d7e0", size = 6886855, upload-time = "2026-07-06T04:21:40.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/36/5ed2e28b8c5f00599c7d5e94d5f82c32cf73a6fa42d13c2900cb37c861ec/grpcio-1.82.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4665dc8c9ec30acff455e2b15c47b825f18647c555483aa1ccf670adead8adcc", size = 7501322, upload-time = "2026-07-06T04:21:43.78Z" }, - { url = "https://files.pythonhosted.org/packages/89/4e/69113709e14e24289940d6aef067b797c73c8c96f580683af7d5f09b22b0/grpcio-1.82.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4405d19ecfc7a8caa9043ff5a651e1871b54fc620f917de5dede7e6fb78e9e14", size = 8536896, upload-time = "2026-07-06T04:21:46.387Z" }, - { url = "https://files.pythonhosted.org/packages/30/f6/08bd6eb89a558f0f59dacaef747afda7c21e2c2ecf138ae2ec533dea8aeb/grpcio-1.82.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e580c19178609799660f52b1e1eb09248969be20dc44caaa4dcaa3e8450dd9", size = 7913890, upload-time = "2026-07-06T04:21:49.733Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a1/04b467e2ffc93f8e50b660bc6f47c3c8e9ba7603104a741e3d5663a261d4/grpcio-1.82.0-cp312-cp312-win32.whl", hash = "sha256:dbbd83dbaa856b387a4eba74ec3add7f594f090d3b4d390d829dd5834816aae4", size = 4240969, upload-time = "2026-07-06T04:21:52.281Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/15d3601384d5937e8d9fbe6d8e7b8171008d649744dd7b6c864932937ec9/grpcio-1.82.0-cp312-cp312-win_amd64.whl", hash = "sha256:73176d270699d76a9dc3753f25e01c19fb98f830f826a506e917d83629f1b39b", size = 5001575, upload-time = "2026-07-06T04:21:54.839Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c8/7aace81e7cd12c6ffccf0d47ac389a3f19e5280e9ab04d301d02855ecd25/grpcio-1.82.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:841bcaa24882921d41cd7a82118f9a766edcf8807c2f486e7deeb0ff5ea36181", size = 6146066, upload-time = "2026-07-06T04:21:57.527Z" }, - { url = "https://files.pythonhosted.org/packages/10/17/b448f2265e4927188425c0b09fb943c39b50f7f53fbead644b44ccdf834b/grpcio-1.82.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d4bb7219c39c735e6573ae3c33aeafb2a4e1f1cc93a762f7899c283defaed365", size = 11948617, upload-time = "2026-07-06T04:22:00.066Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a3/fba54e50fffc9f74b1ffd5eb4e47310e6650557ef136a165f1fe7df03a65/grpcio-1.82.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:254230d2cb773a87380858d0fc74653b4761bf2013c83fe1c5d77ed8f241fc9d", size = 6714591, upload-time = "2026-07-06T04:22:04.04Z" }, - { url = "https://files.pythonhosted.org/packages/29/6f/f0ec3f1a61a746dc7037a737ba4cbe60959645311ac8542bb1ad4e72ae8b/grpcio-1.82.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:16d4f215344057eb21038319b05a0c531306230c17b075fa8461a944581006dd", size = 7454986, upload-time = "2026-07-06T04:22:06.776Z" }, - { url = "https://files.pythonhosted.org/packages/b6/11/9d6ce94465d6a7f92c413430b3e77f0879b39427a217ffb3d23585df4bb3/grpcio-1.82.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a7c283b459151a2e84df32b28c31562ab3e7f624bccffce176c5608565b0212f", size = 6888623, upload-time = "2026-07-06T04:22:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/51/1a/b887adb7abb69081c4f24bc66c3612975e87caf5f7c6aa3916bb82d42e5f/grpcio-1.82.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96a3be4d2a482ff004c036f6391f33553b36a7627138d1ca3ecc1e70d55d8af5", size = 7505067, upload-time = "2026-07-06T04:22:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/60/94/90f11c0f227ac653cff769e05c7f279da945e134c9351a1c37d475714686/grpcio-1.82.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bc481e6c7e6c8721797f2ab764092c843fd5cea06d4c7e9f4c3e3b7ecf331eb0", size = 8535382, upload-time = "2026-07-06T04:22:14.453Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3d/de8569386685213135dfbaab58e221add11858485524cf7d5987efc6d70b/grpcio-1.82.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:680f633bc788652222cb90a568ec374edcd91e1fe7715a7c248ece8e1032c2bd", size = 7910708, upload-time = "2026-07-06T04:22:17.994Z" }, - { url = "https://files.pythonhosted.org/packages/42/ab/3950b32369763818ae89ef76d52e84bd034d3a0ab46bf315669c913b32e2/grpcio-1.82.0-cp313-cp313-win32.whl", hash = "sha256:640b4715b9c206e5176e46601aa1422c47c779f642a869dfd27062e8fde13dfb", size = 4240351, upload-time = "2026-07-06T04:22:20.626Z" }, - { url = "https://files.pythonhosted.org/packages/a7/5c/c7b9a48efe973883f5707a311f87772a4c307a22ec80d6f3c851846a0a02/grpcio-1.82.0-cp313-cp313-win_amd64.whl", hash = "sha256:8523e81bd23f83e607aed28fbd8244b2be4aeb34e084fcef1bd1867709085c2a", size = 5000985, upload-time = "2026-07-06T04:22:23.365Z" }, - { url = "https://files.pythonhosted.org/packages/bd/6e/abc5efe11b73eefa0183531032eef2a53f33aeced6484b0d9da559c12ed6/grpcio-1.82.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:136969867c14fcc743fa39b12d7da133cae7da4f8e0e7767b6b8b7e75a8c24fd", size = 6146898, upload-time = "2026-07-06T04:22:26.082Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e4/d01ee88ceac221436dce1584ae1f046bd44d0dffc5a92e95aa34857c4516/grpcio-1.82.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a7e246cd216662f513ae79ea3afadde6afe8a659b48a4170bae9a896e69ac254", size = 11954897, upload-time = "2026-07-06T04:22:29.114Z" }, - { url = "https://files.pythonhosted.org/packages/99/b9/2a35540ec08afc08859c35afd1a8a51e2585f39b12cf4eee68dcb1fa973d/grpcio-1.82.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:85c2a4e9e8a30d73d41e547a5101e57a1ed2093ec4b2daf6847c344adba00523", size = 6723102, upload-time = "2026-07-06T04:22:32.146Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ba/af71af59943b5d08879617a1d97495d45c53e43f3c6ef16f820c64e107f9/grpcio-1.82.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a3a7802328e0d20e6ac458ff1974a8096cfaa3ca84dea0903235f641f1d703c1", size = 7454545, upload-time = "2026-07-06T04:22:34.776Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7e/258aae12b68910140725873657469ff166f8968bdb0674e12d6452b1812c/grpcio-1.82.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9331e6b91b26cffd63fb70a545f3c2cac8dc1b434b4436f43915c23e1e22f265", size = 6889585, upload-time = "2026-07-06T04:22:37.411Z" }, - { url = "https://files.pythonhosted.org/packages/5e/04/cfada8930306b9101cfb405b33ecb3b72abce51d725f900f167ffbc3146b/grpcio-1.82.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:670ab93a0059e707de53b9e715304b9f566b6defee80e5490cdd15820ce032f6", size = 7514162, upload-time = "2026-07-06T04:22:40.076Z" }, - { url = "https://files.pythonhosted.org/packages/2e/57/955f95989338857a6d73ee838b4d9e8198d50972a70ca83ec22322396f4c/grpcio-1.82.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbfe2e321bb30b84799677d6e8469896e487b494e9e4fbc9b8cc4425fe054d44", size = 8536163, upload-time = "2026-07-06T04:22:42.801Z" }, - { url = "https://files.pythonhosted.org/packages/06/62/826da0b0f01c7ba3f7ea0185968f551290deb28968d98b1edf604c895db8/grpcio-1.82.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2472b72a55da8570e089f1ca22f34a350d86c109be2755f6ee1cca5ebbdd9b54", size = 7912573, upload-time = "2026-07-06T04:22:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b4/d8824a66ac1ac7fbcdf63806f1a8725d95426654e859afa4606070c78740/grpcio-1.82.0-cp314-cp314-win32.whl", hash = "sha256:c9feb986151c2726789599b3672c1c9faa1d0824da921dc3f33a8cb1f93e2861", size = 4321824, upload-time = "2026-07-06T04:22:48.866Z" }, - { url = "https://files.pythonhosted.org/packages/da/08/02e581cc0bbb4c7b842f85e66a945b46d5bcc5927575c1086edfbc3360e7/grpcio-1.82.0-cp314-cp314-win_amd64.whl", hash = "sha256:b1eeb0480d86965263f8c8ae7ee5360c284d22176a196aab02fce7fba8d89dd8", size = 5141112, upload-time = "2026-07-06T04:22:51.443Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e2/b1/46539f5050d7c316a13396d185451f95084a74ddc68b12d818595bef0377/grpcio-1.83.1.tar.gz", hash = "sha256:9cee6fcbf2eb57c4b49451787bfa87be8efc1ca02a0b327dd4b54d44502e362b", size = 13445033, upload-time = "2026-08-28T07:09:11.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/9e/a3ba13e08bbee5bf6e57597dfe4823961fd7e94c0b8afe3a4bb7dca639f3/grpcio-1.83.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:5acd14c6ddf047de62cbf8745b11103ea91abbf57d1b8edd5395ccd9fcd13abb", size = 6303170, upload-time = "2026-08-28T07:08:08.188Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ae/65ce56a2527faa17d02cba4c2231c74047ad898be339486ba87f093bfb66/grpcio-1.83.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:16138031a47b771860a16a975b53087f4fd5bbdbb2c03a188c5d90ad65d2bdae", size = 12165806, upload-time = "2026-08-28T07:08:10.309Z" }, + { url = "https://files.pythonhosted.org/packages/4e/91/40432480088a2243d360864de072ed5b78c4ebbaabd29c28918f1e1b1454/grpcio-1.83.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5ccc26715fd4defca5e129e280dd883b1737b65045ec50ffe22ce42104089519", size = 6872490, upload-time = "2026-08-28T07:08:12.355Z" }, + { url = "https://files.pythonhosted.org/packages/c8/62/3da2300c8c79fd20a78a8a4bb6251e5068d9af33bc8fd389b98fec35e8a3/grpcio-1.83.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b74f2a1d9ab1dfa3e263ef33d581613679b78d0884babf11671af26e45570ead", size = 7618367, upload-time = "2026-08-28T07:08:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/bc/19/9fc702e31a631262d7a752fa699f6022821e707fefc8bff49b1550a57729/grpcio-1.83.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72578aa07a4008f17521ef52debcc3acfd1e2c5426243bc3ffb56a38bfe610b7", size = 7040936, upload-time = "2026-08-28T07:08:15.963Z" }, + { url = "https://files.pythonhosted.org/packages/ec/56/95933cc44cba2429765fa065c951dd529e5771b119d9d2481b4646f1d6a5/grpcio-1.83.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c12e1fc59c6dc26d10d9144453ddc6cbfe4cd4c31e874ed2d0132f88e685eb8b", size = 7573096, upload-time = "2026-08-28T07:08:17.729Z" }, + { url = "https://files.pythonhosted.org/packages/ac/80/af63359da06b016de48cb111f144703a10043850dafa43ae0a038907b9e8/grpcio-1.83.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4910b62f7d12197160bfb7de06d876d64dd12d43483e8292f98f49ca09b628d9", size = 8609442, upload-time = "2026-08-28T07:08:19.777Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fa/f0586c56bdfb8a7a2adda01e0ac2413447cde3141ab09411a5d5afdcffd3/grpcio-1.83.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e703effe3ae779925c82ac24fdb82cf4105e1096810151ed9501c5f34546b9c", size = 7984321, upload-time = "2026-08-28T07:08:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/25/8a/14ec05669f9eb295801e26c2ea8c561a1b786b0e3557c2c22131165ab010/grpcio-1.83.1-cp312-cp312-win32.whl", hash = "sha256:a2aea8bd6e0a34f12cbaddb7bb70bec836818789fa5c7ab7572c6b745396a2d4", size = 4395604, upload-time = "2026-08-28T07:08:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/e9/37/8c2f7cc16089e36a3fbacaacc7a3d043912aa0d2dfae5556f6450414ea6e/grpcio-1.83.1-cp312-cp312-win_amd64.whl", hash = "sha256:583bf2e8255040a4a312f9572dfe62a05271437b149550e1a536d5c47d2d1e8a", size = 5161512, upload-time = "2026-08-28T07:08:25.81Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fd/d1fc58933bf88c9209f89dc570c810f1aa57cb04b3459cf2b26f61e32112/grpcio-1.83.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:8d228e253b77865efcbdd7b5894ca882c9e0ea98c02b7d20582e61ded8dfd4b5", size = 6305628, upload-time = "2026-08-28T07:08:27.872Z" }, + { url = "https://files.pythonhosted.org/packages/c4/49/0b40bae059c619505c9b751cee6caa208e4904e290aaefa1728c4c2c67a5/grpcio-1.83.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0468b627f2987c9a77f7580030207cbd85457ffe52998beff4f0b5c38c58a72c", size = 12156839, upload-time = "2026-08-28T07:08:30.191Z" }, + { url = "https://files.pythonhosted.org/packages/61/4b/e8c0d635da0ee5ddd9950c8d540f5dcdd0ef1854a382cc55496a487a8d31/grpcio-1.83.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6a282e81530cead60bbd752cc04950a57f224379e9821495d6a35bd5ce9b1f4", size = 6877036, upload-time = "2026-08-28T07:08:32.285Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d4/760a33f339a7dd3d5f4b3e0e9bec5472d95592a80f887b2e9dab4e41cfbc/grpcio-1.83.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:947d945f52e8ecf3cafd2bb7113502a16ccfda3e12c854443094de32d83ad432", size = 7624404, upload-time = "2026-08-28T07:08:34.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/bd798654b06fb42a92b57d1dc1b530084fa89ed442806fcd0a833a36f9b3/grpcio-1.83.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:55656318d5dd387077396dffb929171ca3966e24bfead9a6c5dba9f889062cb4", size = 7042942, upload-time = "2026-08-28T07:08:36.208Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/c00f86614566dd0961825cf0f43d4f96a74371d9d95f952bcbc4b86d9a27/grpcio-1.83.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9daf5acf4fc9d5f5627229969c2580a91e511779d76e4ccdeb9f4770f05d8bc2", size = 7576937, upload-time = "2026-08-28T07:08:38.041Z" }, + { url = "https://files.pythonhosted.org/packages/b1/38/85eff43a5c89dc666a252b5c9f8e9ab03f89e11c95b6263d2933f08fdbe7/grpcio-1.83.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7b94174cbca93316888f805efbeb08f1c020f7b7493d2d50cc4f6b64ebb7e8bd", size = 8608391, upload-time = "2026-08-28T07:08:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/35/4e/82835483e2f812494be865e7965c0d626cb9e71ab0d83a420d75aea4ad67/grpcio-1.83.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65c5a7210911ffe0f67b1cdc5308f9854b6d1f1b345e3e49ab7cac1ba50fa346", size = 7980060, upload-time = "2026-08-28T07:08:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/68a98bef733fef704fbcfb3957c8dba67e3e38ca7a7fea851195bc97c648/grpcio-1.83.1-cp313-cp313-win32.whl", hash = "sha256:179368d9361854616ce6f397d4716e07480129652752fcbcfc5a7260455ad6f2", size = 4395226, upload-time = "2026-08-28T07:08:44.463Z" }, + { url = "https://files.pythonhosted.org/packages/85/a0/df4de3b51d37ac8fb0320bb9668381ce2bd3b7aa990880bfc56a8a26f665/grpcio-1.83.1-cp313-cp313-win_amd64.whl", hash = "sha256:2e57af456385491a76e13c4aada8c8f43a8e47051e06ea97a9dbe2a49654e6db", size = 5160273, upload-time = "2026-08-28T07:08:46.216Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/484d981d8b90c4e6abf3030bd2ed747e84d1eb192b3ec9cbb41e0b73e4bf/grpcio-1.83.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:8b3c87ca908296bf125f841d3e1a2225a2b39aaa8ed7a57e7ccde465ee519bab", size = 6306089, upload-time = "2026-08-28T07:08:48.379Z" }, + { url = "https://files.pythonhosted.org/packages/84/01/0afec1c92e4f292f74a44ecf75eabbf40903125b8c4df103c9868d6338da/grpcio-1.83.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c0f3f20c90e72a171917ae65706500b096a1c3eb5f162c3ce702a2e25635f132", size = 12170381, upload-time = "2026-08-28T07:08:50.653Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5a/e9a2383804433a0a61d6d93777ad321c7f36ac1cfdaa4c6d1a7c9ac846b7/grpcio-1.83.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81bbf35a46bf8cad2dfbb2eccc19c711befb58b288acb534bbcd0d74283202a6", size = 6883286, upload-time = "2026-08-28T07:08:53.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/f8ca8f76994e14c70b9a0052e82f10de497a23db450c36379c9716ebfc4d/grpcio-1.83.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:215cec07d11176507387bda4bf2751816e880f9bff8dc1ca524bfbb8ed8f2fad", size = 7624293, upload-time = "2026-08-28T07:08:55.709Z" }, + { url = "https://files.pythonhosted.org/packages/bb/7a/4b672814b0cd0fe63bdd735379d88b165759f3144ab023ad8ec5fc4d53ac/grpcio-1.83.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:abce7d43ec29cd39230fa8339de1a07643b55adc412a454850fbd875349950ff", size = 7044346, upload-time = "2026-08-28T07:08:57.802Z" }, + { url = "https://files.pythonhosted.org/packages/50/b8/d89fe60e4239ad51be333dd9cc703741d449a35064e51f8a0b5bfa755432/grpcio-1.83.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e256f95a40e3b0183a98556fb7164d24b97eeb353123ccabfcba94712b35ee2a", size = 7584187, upload-time = "2026-08-28T07:08:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b2/b290d7402633d9166e4dd47e6f5f74a24ce10a8340b84455896ebc349f85/grpcio-1.83.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2110059146fb0ea216e1ffddb29377b5cc2fd412a5b0a92e102616bd5edf18c2", size = 8608730, upload-time = "2026-08-28T07:09:02.592Z" }, + { url = "https://files.pythonhosted.org/packages/f5/44/fa89e44d1b5cf5b9fa71b2fd7abf506f182fd43917231a92fbf1ea326b02/grpcio-1.83.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20d944d967843f8183f9f23d5916388362e5f8eeeae855bbe4354d906dc9f31b", size = 7983283, upload-time = "2026-08-28T07:09:05.087Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/9db73ed1f35ffa76124ac574bf296d06a359798dfd6b50d382f2b8a060a1/grpcio-1.83.1-cp314-cp314-win32.whl", hash = "sha256:623c87c6d4a1cb30d82c4e896f95477050f2e01b4a1f8cf91ff2b1abdf89c457", size = 4474327, upload-time = "2026-08-28T07:09:07.179Z" }, + { url = "https://files.pythonhosted.org/packages/65/22/fc9a622d885a7a37ff972a12faaef443d74e47407181da70d0ab62ab41f0/grpcio-1.83.1-cp314-cp314-win_amd64.whl", hash = "sha256:47e6934ad38779271e2e7cc5f78a63a407cf3d98114c65c1fdbcd3f5a716f29b", size = 5302032, upload-time = "2026-08-28T07:09:09.285Z" }, ] [[package]] name = "grpcio-status" -version = "1.82.0" +version = "1.83.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/c3/42148fdb28747b6c12df1351dece3fd574262fa14b180eca9821428626df/grpcio_status-1.82.0.tar.gz", hash = "sha256:ea0cbc12c52a902cb33ba56fb3233dcfad53ffead08f4a659b1752d473a6a0ac", size = 13902, upload-time = "2026-07-06T04:26:47.864Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/03/dee41fe15a9657c60397c1f215622a1f146e174367bbbb67911b62ee0629/grpcio_status-1.83.1.tar.gz", hash = "sha256:c08c8d553d6ab96effae1d923839de22926f5a316a942f48a48040e047c517af", size = 13957, upload-time = "2026-08-28T07:12:25.893Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/f3/fe7e1f57f170ed6cae20eecc0dea62e9de862e68adede80a6cd54a9b4dfa/grpcio_status-1.82.0-py3-none-any.whl", hash = "sha256:d41e45164ae2c265b268aebd9d86defa292a1ba91d86a548bad1f059c4645676", size = 14634, upload-time = "2026-07-06T04:26:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/b03ec688e5c8c6ce283ec8b214d9c171e97059eb55157465f6fd6db1562c/grpcio_status-1.83.1-py3-none-any.whl", hash = "sha256:2cd328ee62ef2b3eb957dd3b75db7dadcdbb76488fdc9ab3aba1ebfbbdc324a4", size = 14636, upload-time = "2026-08-28T07:12:15.982Z" }, ] [[package]] @@ -1853,7 +1772,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.100.0" +version = "1.101.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1871,40 +1790,40 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/ce/1e1ce2558f65244057c0c40c60ade4dfa3767da3522bf1dd8679507ad7ba/litellm-1.100.0.tar.gz", hash = "sha256:ece94e817a453a5b3a9517c03547c428d501cea719edb728c1b260e53f78ea35", size = 17287857, upload-time = "2026-09-06T00:22:46.023Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/c0/08a31c8c5f7fce96e98e2a7f818f446d0468da484228a7450cd2b6f71dcf/litellm-1.101.0.tar.gz", hash = "sha256:734ab2b8cad6a3b582d52d9c9c5fcab759eb382b93935ef808fda0e16d822ac3", size = 17447699, upload-time = "2026-09-14T23:15:36.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/92/983d15efffd9bab37ebbff255fb09417814ecd959bd2a2459b8afb6c205d/litellm-1.100.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:098a413e398e2220734cf9c8dd75fb34a38b65b64090619c2869af1fdeaf4ae5", size = 23899430, upload-time = "2026-09-06T00:22:21.941Z" }, - { url = "https://files.pythonhosted.org/packages/59/e1/ea3f868ad6c84b2232f41d5d56adb5f0794ebb1cf32fc0e78effa5842170/litellm-1.100.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0f87fae695edbca27e5cf970bea52fa405fa9910fdf7c29c963d6413db767299", size = 23552365, upload-time = "2026-09-06T00:22:25.456Z" }, - { url = "https://files.pythonhosted.org/packages/61/50/9439bd3238c9d8c7efcd5165555f175cc8e99e748ba99e8cc7e502787265/litellm-1.100.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a07370d116905485e9ac99679bac991a0a6c81e13c99ca3f007128fbdf2b0082", size = 23693340, upload-time = "2026-09-06T00:22:28.549Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/34a1df3a5fe1db92bf43d3b25af2e5a9c36c54ab6813acef0c31cce2b4e0/litellm-1.100.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8224c8eed9cab3319a88e6665d1275ad8faf21d353b1b22223a6d6115a302ea2", size = 24063361, upload-time = "2026-09-06T00:22:31.913Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d0/4c7e8c8402e5af8c9f55602484c2bc888f0a8fe5c2a01df2a3705ba2139b/litellm-1.100.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e3787fb7ad1f20aebdde7686a85061880bba6550c9d62bfa1cf8df089fe7899b", size = 23766707, upload-time = "2026-09-06T00:22:35.06Z" }, - { url = "https://files.pythonhosted.org/packages/4e/a2/81595fcda1457b777739d265b9383c1061c884af37111223b5fecbf72588/litellm-1.100.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0b7ec93013e18535481cd811b776ee95c6b957b3a9fb44dc53f7c459e7e60e38", size = 24163673, upload-time = "2026-09-06T00:22:38.418Z" }, - { url = "https://files.pythonhosted.org/packages/ce/96/1ee7a3c86c4e9afec528d094b30158cd55b8bbc619e1632ad897bbd1291a/litellm-1.100.0-cp310-abi3-win_amd64.whl", hash = "sha256:c6f2f56808d05d8d2a7766129d958101eafb1a47e30ba5ddcfa900cdbc50af67", size = 23974360, upload-time = "2026-09-06T00:22:42.783Z" }, + { url = "https://files.pythonhosted.org/packages/ae/cf/6b3a687ab0ab4caacc35faaf695f6862654e21e2a5dc703fa9e1df6d7069/litellm-1.101.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7cc623a224c6f11a04367a682b095a1e08e5f6da75c990a650910db5f9777819", size = 23777389, upload-time = "2026-09-14T23:15:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/42/f6/d5bc3aa1944244fd186e65ca16e62158f0f474b40f99a89e568a5d441739/litellm-1.101.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d4064024151ff2877e542b56c6bb6a39e0c3e6651abf4639af9346a678586e52", size = 23434831, upload-time = "2026-09-14T23:15:15.245Z" }, + { url = "https://files.pythonhosted.org/packages/ba/12/755e4d497b975911449102b8b74d3d4f770137c4467e3e8fe304adf22d66/litellm-1.101.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:cdf3351e394206e785bf339f1a39d9358ddf4e4f38129750bfdb5e4f5ceab8ef", size = 23568059, upload-time = "2026-09-14T23:15:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/0c/0e/561d7a314a08940688814be590544907fdfde9225dce35badbd79fc9ad4c/litellm-1.101.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:140ee0997324b8fd2405f7f8c26f42c0f364996dc4030187344a90cf27916667", size = 23944856, upload-time = "2026-09-14T23:15:26.417Z" }, + { url = "https://files.pythonhosted.org/packages/f1/df/d640eb6cf4a304be4ec289b72c1b1d9bd66c73e90167211d3a09ac37b2d8/litellm-1.101.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ad013161074676fc91b91d828f696300132cf3936d3f386c4701a41710d70aa3", size = 23643397, upload-time = "2026-09-14T23:15:28.664Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b6/9d69e95de9465cd23651c6623518d5e711d8776a0e58e6ea62066c9d9470/litellm-1.101.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d88053148c5c6e016e495273eeaf8f3a29779b60d18977ec42e2a6bbd2d8eb", size = 24042718, upload-time = "2026-09-14T23:15:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b5/0a54001992c9a8d991f6ddd7532e3c4cb7dbdcc94220a37586513aab6f89/litellm-1.101.0-cp310-abi3-win_amd64.whl", hash = "sha256:e5601ae404b0f1e38ce1e65c75f880254485942d9c824453e3926f2323457fd5", size = 23844988, upload-time = "2026-09-14T23:15:33.632Z" }, ] [[package]] name = "loguru" -version = "0.7.2" +version = "0.7.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "win32-setctime", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/30/d87a423766b24db416a46e9335b9602b054a72b96a88a241f2b09b560fa8/loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac", size = 145103, upload-time = "2023-09-11T15:24:37.926Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/0a/4f6fed21aa246c6b49b561ca55facacc2a44b87d65b8b92362a8e99ba202/loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb", size = 62549, upload-time = "2023-09-11T15:24:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] [[package]] name = "mangum" -version = "0.17.0" +version = "0.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/3d/c9664d56ad6b32d7c7ebf82a77cacc290ebed033f96adf0c98a9114ace4e/mangum-0.17.0.tar.gz", hash = "sha256:5b4e26375e12eed051687670466d17968f8b74beecaca432edd4eb4127f78509", size = 14222, upload-time = "2022-11-27T11:21:42.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/c8/1579c705908d02a4526a748e8d0f0419151d4498c59033840f5df3a14822/mangum-0.22.0.tar.gz", hash = "sha256:701936891aaed8ed66479b871c2bda87ba288fd01c7d5dd6ba6c8e9ba1c239d1", size = 118290, upload-time = "2026-08-22T12:54:48.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/3d/aa091b88f5fe2c4d73fd42831affc4aab26cdbdf0bab9925e1cabf297aaa/mangum-0.17.0-py3-none-any.whl", hash = "sha256:f00be705605bc4793958df62e4d249abf58d254c39d90bb410d069570206f4a2", size = 17191, upload-time = "2022-11-27T11:21:40.261Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a2/21ee5d457ae79a2587ff0d4652990670b7cac84ba0eaab6e6865a3bd2515/mangum-0.22.0-py3-none-any.whl", hash = "sha256:a0595e3cc7a8091b22d8b3997bab5b2ad6aae7a5e40865e19c2015f5c959a93b", size = 17143, upload-time = "2026-08-22T12:54:46.924Z" }, ] [[package]] @@ -1996,6 +1915,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, ] +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, +] + [[package]] name = "msrest" version = "0.7.1" @@ -2452,45 +2411,24 @@ wheels = [ ] [[package]] -name = "pinecone-client" -version = "6.0.0" +name = "pinecone" +version = "10.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "pinecone-plugin-interface" }, - { name = "python-dateutil" }, - { name = "typing-extensions" }, - { name = "urllib3", marker = "python_full_version < '4'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6c/ab/3ab3b81e8ad82fbfcaa4f446c7f962b18968d61543c8c9e2c38bd777c056/pinecone_client-6.0.0.tar.gz", hash = "sha256:f224fc999205e4858c4737c40922bdf42d178b361c8859bc486ec00d45b359a9", size = 7004, upload-time = "2025-02-21T19:47:38.899Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/e4/7780cd631dc6dad0172a245e958b41b28a70779594c0790fa08b952aa97f/pinecone_client-6.0.0-py3-none-any.whl", hash = "sha256:d81a9e73cae441e4ab6dfc9c1d8b51c9895dae2488cda64f3e21b9dfc10c8d94", size = 6654, upload-time = "2025-02-21T19:47:37.23Z" }, -] - -[[package]] -name = "pinecone-datasets" -version = "0.6.1" -source = { git = "https://github.com/mrT23/pinecone-datasets.git?rev=main#9018d935d27e9bcc2b8de4c151228c60c6cc10da" } -dependencies = [ - { name = "fsspec", version = "2025.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "fsspec", version = "2026.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, - { name = "gcsfs", version = "2025.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "gcsfs", version = "2026.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, - { name = "pandas" }, - { name = "pinecone-client" }, - { name = "pyarrow" }, - { name = "pydantic" }, - { name = "s3fs" }, - { name = "tqdm" }, + { name = "anyio" }, + { name = "httpx", extra = ["http2"] }, + { name = "msgspec" }, + { name = "orjson" }, ] - -[[package]] -name = "pinecone-plugin-interface" -version = "0.0.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/fb/e8a4063264953ead9e2b24d9b390152c60f042c951c47f4592e9996e57ff/pinecone_plugin_interface-0.0.7.tar.gz", hash = "sha256:b8e6675e41847333aa13923cc44daa3f85676d7157324682dc1640588a982846", size = 3370, upload-time = "2024-06-05T01:57:52.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/97/f04b682e5d65d4cd09b76afd691f8834cf2c28d82bc942ad637a2c15c74d/pinecone-10.0.0.tar.gz", hash = "sha256:6066bbe9a7ae1d667cde08d262deb6fbea6feb35deb9177dd47141b55bbd9833", size = 525129, upload-time = "2026-09-03T14:31:22.729Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/1d/a21fdfcd6d022cb64cef5c2a29ee6691c6c103c4566b41646b080b7536a5/pinecone_plugin_interface-0.0.7-py3-none-any.whl", hash = "sha256:875857ad9c9fc8bbc074dbe780d187a2afd21f5bfe0f3b08601924a61ef1bba8", size = 6249, upload-time = "2024-06-05T01:57:50.583Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c7/9d61f9a4adaaa6e315a0425c2127b449bda64633a9c50bc533ff71c0c894/pinecone-10.0.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:995c06e905940b10bb2aefe653225340b5a3f56f3efb373fe07f4e57b5043705", size = 3098155, upload-time = "2026-09-03T14:31:11.281Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/d73c0aa1030931a1b28151c4ff727b793e443a4531521bc89a1d4c3124a0/pinecone-10.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0994270c514b16c72ec94dd6c29ff2708b81d30ff8467e19de192a28a7c86b7e", size = 3070497, upload-time = "2026-09-03T14:31:12.998Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3e/3b9dba42741ed8faf011d7bce8e1fd10f45dd0503e2888eceafa6be33424/pinecone-10.0.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d482ed27a805cbd4aca2660da212008dd6e41d87d255279f405ff13b725970e2", size = 3353834, upload-time = "2026-09-03T14:31:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/22/35/50e23cac1d73c921b8cc2eb02c9ad2691f62e8b906db49237bcf85f90676/pinecone-10.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94d4c64779f3213a5cc538d3bd10a873da192cb9b0039db56690e556ba00b55c", size = 3291380, upload-time = "2026-09-03T14:31:16.052Z" }, + { url = "https://files.pythonhosted.org/packages/04/ef/705e49608eac5803b9b55b6cfae2cac736c0395e709f7668f52c89afc99b/pinecone-10.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0e05956a3201b1fbb54a1861277df919318a4941797f2d87fd558ac5ec232151", size = 3528743, upload-time = "2026-09-03T14:31:17.558Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a4/11795c174225b9dea5054618c91b956d0a26b3d7167a5d3531e167712e83/pinecone-10.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3ab0c843b4fb04fbac22f1b8455e389063208a50f6a95d7a90627939968198de", size = 3574713, upload-time = "2026-09-03T14:31:19.198Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5d/93d123cfcf458ccc2defa4c0363be5fb7aabfcd375cd2414a3e944e7f55a/pinecone-10.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:2f4e3200ee3562d195802b363487dd7fe6039a8c13630fc25fa3e8726c7a8654", size = 2924160, upload-time = "2026-09-03T14:31:21.107Z" }, ] [[package]] @@ -2525,7 +2463,7 @@ wheels = [ [[package]] name = "pr-agent" -version = "0.45.0" +version = "0.46.0" source = { editable = "." } dependencies = [ { name = "a2a-sdk", extra = ["http-server"] }, @@ -2554,6 +2492,7 @@ dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-sdk" }, + { name = "prometheus-client" }, { name = "protobuf" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -2596,8 +2535,7 @@ dev = [ similar-issue = [ { name = "lancedb" }, { name = "pandas" }, - { name = "pinecone-client" }, - { name = "pinecone-datasets" }, + { name = "pinecone" }, { name = "qdrant-client" }, ] @@ -2610,11 +2548,11 @@ requires-dist = [ { name = "azure-devops", specifier = "==7.1.0b4" }, { name = "azure-identity", specifier = "==1.25.0" }, { name = "boto3", specifier = "==1.43.78" }, - { name = "certifi", specifier = "==2024.8.30" }, - { name = "dynaconf", specifier = ">=3.2.13,<4" }, + { name = "certifi", specifier = ">=2026.7.22" }, + { name = "dynaconf", specifier = ">=3.3.5,<4" }, { name = "fastapi", specifier = ">=0.141.1,<1" }, { name = "giteapy", specifier = "==1.0.8" }, - { name = "gitpython", specifier = ">=3.1.59,<4" }, + { name = "gitpython", specifier = ">=3.1.62,<4" }, { name = "google-cloud-aiplatform", specifier = "==1.154.0" }, { name = "google-cloud-storage", marker = "python_full_version < '3.13'", specifier = "==2.10.0" }, { name = "google-cloud-storage", marker = "python_full_version >= '3.13'", specifier = "==3.12.0" }, @@ -2625,19 +2563,20 @@ requires-dist = [ { name = "langchain-core", marker = "extra == 'langchain'" }, { name = "langchain-openai", marker = "extra == 'langchain'" }, { name = "langfuse", specifier = "==3.14.5" }, - { name = "litellm", specifier = "==1.100.0" }, - { name = "loguru", specifier = "==0.7.2" }, - { name = "mangum", marker = "extra == 'lambda'", specifier = "==0.17.0" }, + { name = "litellm", specifier = "==1.101.0" }, + { name = "loguru", specifier = "==0.7.3" }, + { name = "mangum", marker = "extra == 'lambda'", specifier = "==0.22.0" }, { name = "msrest", specifier = "==0.7.1" }, { name = "openai", specifier = ">=1.55.3" }, { name = "opentelemetry-api", specifier = "==1.43.0" }, { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "extra == 'otel-grpc'", specifier = "==1.43.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = "==1.43.0" }, { name = "opentelemetry-sdk", specifier = "==1.43.0" }, + { name = "prometheus-client", specifier = "==0.21.1" }, { name = "protobuf", specifier = "==6.33.6" }, { name = "pydantic", specifier = "==2.13.3" }, { name = "pydantic-settings", specifier = "==2.14.2" }, - { name = "pygithub", specifier = "==1.59.*" }, + { name = "pygithub", specifier = ">=2.10,<3" }, { name = "pyjwt", specifier = ">=2.13.0,<3" }, { name = "python-gitlab", specifier = "==8.3.0" }, { name = "pyyaml", specifier = "==6.0.1" }, @@ -2659,13 +2598,12 @@ dev = [ { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-asyncio", specifier = ">=1.4.0" }, { name = "pytest-cov", specifier = "==7.1.0" }, - { name = "ruff", specifier = "==0.16.5" }, + { name = "ruff", specifier = "==0.16.8" }, ] similar-issue = [ { name = "lancedb", specifier = "==0.38.0" }, { name = "pandas" }, - { name = "pinecone-client" }, - { name = "pinecone-datasets", git = "https://github.com/mrT23/pinecone-datasets.git?rev=main" }, + { name = "pinecone", specifier = "==10.0.0" }, { name = "qdrant-client", specifier = "==1.15.1" }, ] @@ -2685,6 +2623,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/14/7d0f567991f3a9af8d1cd4f619040c93b68f09a02b6d0b6ab1b2d1ded5fe/prometheus_client-0.21.1.tar.gz", hash = "sha256:252505a722ac04b0456be05c05f75f45d760c2911ffc45f2a06bcaed9f3ae3fb", size = 78551, upload-time = "2024-12-03T14:59:12.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/c2/ab7d37426c179ceb9aeb109a85cda8948bb269b7561a0be870cc656eefe4/prometheus_client-0.21.1-py3-none-any.whl", hash = "sha256:594b45c410d6f4f8888940fe80b5cc2521b305a1fafe1c58609ef715a001f301", size = 54682, upload-time = "2024-12-03T14:59:10.935Z" }, +] + [[package]] name = "propcache" version = "0.5.2" @@ -2994,17 +2941,18 @@ wheels = [ [[package]] name = "pygithub" -version = "1.59.1" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "deprecated" }, { name = "pyjwt", extra = ["crypto"] }, { name = "pynacl" }, { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/30/203d3420960853e399de3b85d6613cea1cf17c1cf7fc9716f7ee7e17e0fc/PyGithub-1.59.1.tar.gz", hash = "sha256:c44e3a121c15bf9d3a5cc98d94c9a047a5132a9b01d22264627f58ade9ddc217", size = 3295328, upload-time = "2023-08-03T09:43:01.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/9b/195603d5371861005a3467c5e4afd02fd0698795a2aa36dc41498b9d879d/pygithub-2.10.0.tar.gz", hash = "sha256:90ff24ef1cd1bd57124c2a3869cafee9d7b066909129ecdaba2c2d1903bc118d", size = 2750952, upload-time = "2026-08-20T10:05:08.327Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/71/aff5465d9e3d448a5d4beab1dc7c8dec72037e3ae7e0d856ee08538dc934/PyGithub-1.59.1-py3-none-any.whl", hash = "sha256:3d87a822e6c868142f0c2c4bf16cce4696b5a7a4d142a7bd160e1bdf75bc54a9", size = 342171, upload-time = "2023-08-03T09:43:00.046Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/f314841697a1d52af3e1ea7c5e1c3f09685b64ae4c58ff16b605a866255d/pygithub-2.10.0-py3-none-any.whl", hash = "sha256:192ada2a76e4afc7d6b37e500c9bfeba1731e6506697445a5ba1c4af8bf0b924", size = 455554, upload-time = "2026-08-20T10:05:06.991Z" }, ] [[package]] @@ -3473,41 +3421,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, - { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, - { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, - { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, - { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, - { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, - { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, - { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, - { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, - { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, -] - -[[package]] -name = "s3fs" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore" }, - { name = "fsspec", version = "2025.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "fsspec", version = "2026.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/504cb277632c4d325beabbd03bb43778f0decb9be22d9e0e6c62f44540c7/s3fs-0.4.2.tar.gz", hash = "sha256:2ca5de8dc18ad7ad350c0bd01aef0406aa5d0fff78a561f0f710f9d9858abdd0", size = 57527, upload-time = "2020-03-31T15:24:26.388Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/e4/b8fc59248399d2482b39340ec9be4bb2493846ac23641b43115a7e5cd675/s3fs-0.4.2-py3-none-any.whl", hash = "sha256:91c1dfb45e5217bd441a7a560946fe865ced6225ff7eb0fb459fe6e601a95ed3", size = 19791, upload-time = "2020-03-31T15:24:24.952Z" }, +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, ] [[package]]