mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3846593fb | |||
| 041bc69639 |
+4
-3
@@ -1,7 +1,8 @@
|
||||
# CODEOWNERS file for OpenHands repository
|
||||
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
|
||||
/frontend/ @hieptl
|
||||
/openhands-ui/ @hieptl
|
||||
/frontend/ @amanape @hieptl
|
||||
/openhands-ui/ @amanape @hieptl
|
||||
/openhands/ @tofarr @malhotra5 @hieptl
|
||||
/enterprise/ @chuckbutkus @tofarr @malhotra5 @jlav @aivong-openhands
|
||||
/enterprise/ @chuckbutkus @tofarr @malhotra5
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
|
||||
@@ -1,46 +1,38 @@
|
||||
<!-- Keep this PR as draft until it is ready for review. -->
|
||||
<!-- If you are still working on the PR, please mark it as draft. Maintainers will review PRs marked ready for review, which leads to lost time if your PR is actually not ready yet. Keep the PR marked as draft until it is finally ready for review -->
|
||||
|
||||
<!-- AI/LLM agents: be concise and specific. Do not check the box below. -->
|
||||
## Summary of PR
|
||||
|
||||
- [ ] A human has tested these changes.
|
||||
<!-- Summarize what the PR does -->
|
||||
|
||||
---
|
||||
## Demo Screenshots/Videos
|
||||
|
||||
## Why
|
||||
<!-- AI/LLM AGENTS: This section is intended for a human author to add screenshots or videos demonstrating the PR in action (optional). While many pull requests may be generated by AI/LLM agents, we are fine with this as long as a human author has reviewed and tested the changes to ensure accuracy and functionality. -->
|
||||
|
||||
<!-- Describe problem, motivation, etc.-->
|
||||
## Change Type
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- 1-3 bullets describing what changed. -->
|
||||
-
|
||||
|
||||
## Issue Number
|
||||
<!-- Required if there is a relevant issue to this PR. -->
|
||||
|
||||
## How to Test
|
||||
|
||||
<!--
|
||||
Required. Share the steps for the reviewer to be able to test your PR. e.g. You can test by running `npm install` then `npm build dev`.
|
||||
|
||||
If you could not test this, say why.
|
||||
-->
|
||||
|
||||
## Video/Screenshots
|
||||
|
||||
<!--
|
||||
Provide a video or screenshots of testing your PR. e.g. you added a new feature to the gui, show us the video of you testing it successfully.
|
||||
|
||||
-->
|
||||
|
||||
## Type
|
||||
<!-- Choose the types that apply to your PR -->
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Refactor
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Docs / chore
|
||||
- [ ] Refactor
|
||||
- [ ] Other (dependency update, docs, typo fixes, etc.)
|
||||
|
||||
## Notes
|
||||
## Checklist
|
||||
<!-- AI/LLM AGENTS: This checklist is for a human author to complete. Do NOT check either of the two boxes below. Leave them unchecked until a human has personally reviewed and tested the changes. -->
|
||||
|
||||
<!-- Optional: migrations, config changes, rollout concerns, follow-ups, or anything reviewers should know. -->
|
||||
- [ ] I have read and reviewed the code and I understand what the code is doing.
|
||||
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
|
||||
|
||||
## Fixes
|
||||
|
||||
<!-- If this resolves an issue, link it here so it will close automatically upon merge. -->
|
||||
|
||||
Resolves #(issue)
|
||||
|
||||
## Release Notes
|
||||
|
||||
<!-- Check the box if this change is worth adding to the release notes. If checked, you must provide an
|
||||
end-user friendly description for your change below the checkbox. -->
|
||||
|
||||
- [ ] Include this change in the Release Notes.
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install poetry via pipx
|
||||
uses: abatilo/actions-poetry@v4
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@v4
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
|
||||
@@ -17,20 +17,18 @@ concurrency:
|
||||
jobs:
|
||||
fe-e2e-test:
|
||||
name: FE E2E Tests
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
@@ -21,20 +21,18 @@ jobs:
|
||||
# Run frontend unit tests
|
||||
fe-test:
|
||||
name: FE Unit Tests
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
@@ -30,42 +30,37 @@ env:
|
||||
|
||||
jobs:
|
||||
define-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith
|
||||
outputs:
|
||||
base_image: ${{ steps.define-base-images.outputs.base_image }}
|
||||
platforms: ${{ steps.define-base-images.outputs.platforms }}
|
||||
steps:
|
||||
- name: Define base images
|
||||
shell: bash
|
||||
id: define-base-images
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
|
||||
platforms="linux/amd64"
|
||||
json=$(jq -n -c --arg platforms "$platforms" '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms }
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" }
|
||||
]')
|
||||
else
|
||||
platforms="linux/amd64,linux/arm64"
|
||||
json=$(jq -n -c --arg platforms "$platforms" '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu", platforms: $platforms }
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu" }
|
||||
]')
|
||||
fi
|
||||
echo "base_image=$json" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=$platforms" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Builds the OpenHands Docker images
|
||||
ghcr_build_app:
|
||||
name: Build App Image
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
needs: define-matrix
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
@@ -87,12 +82,12 @@ jobs:
|
||||
- name: Build and push app image
|
||||
if: "!github.event.pull_request.head.repo.fork"
|
||||
run: |
|
||||
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push -p ${{ needs.define-matrix.outputs.platforms }}
|
||||
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
|
||||
|
||||
# Builds the runtime Docker images
|
||||
ghcr_build_runtime:
|
||||
name: Build Runtime Image
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -103,7 +98,7 @@ jobs:
|
||||
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
@@ -122,7 +117,7 @@ jobs:
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: poetry
|
||||
@@ -141,7 +136,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry -p ${{ matrix.base_image.platforms }}
|
||||
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
|
||||
|
||||
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
|
||||
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
|
||||
@@ -149,7 +144,7 @@ jobs:
|
||||
echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
|
||||
- name: Build and push runtime image ${{ matrix.base_image.image }}
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
uses: docker/build-push-action@v6
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_TAGS }}
|
||||
@@ -163,7 +158,7 @@ jobs:
|
||||
# Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
|
||||
- name: Build runtime image ${{ matrix.base_image.image }} for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: docker/build-push-action@v6
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
context: containers/runtime
|
||||
@@ -176,7 +171,7 @@ jobs:
|
||||
|
||||
ghcr_build_enterprise:
|
||||
name: Push Enterprise Image
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -185,7 +180,7 @@ jobs:
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
@@ -215,7 +210,6 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=match,pattern=cloud-\d+\.\d+\.\d+
|
||||
flavor: |
|
||||
latest=auto
|
||||
prefix=
|
||||
@@ -229,7 +223,7 @@ jobs:
|
||||
# rather than a mutable branch tag like "main" which can serve stale cached layers.
|
||||
echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
file: enterprise/Dockerfile
|
||||
@@ -248,7 +242,7 @@ jobs:
|
||||
# We can remove this once the config changes
|
||||
runtime_tests_check_success:
|
||||
name: All Runtime Tests Passed
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: All tests passed
|
||||
run: echo "All runtime tests have passed successfully!"
|
||||
@@ -257,10 +251,10 @@ jobs:
|
||||
name: Update PR Description
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'
|
||||
needs: [ghcr_build_runtime]
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get short SHA
|
||||
id: short_sha
|
||||
|
||||
@@ -9,12 +9,12 @@ jobs:
|
||||
lint-fix-frontend:
|
||||
if: github.event.label.name == 'lint-fix'
|
||||
name: Fix frontend linting issues
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -22,14 +22,13 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
run: |
|
||||
cd frontend
|
||||
npm install --frozen-lockfile
|
||||
- name: Generate i18n and route types
|
||||
run: |
|
||||
cd frontend
|
||||
@@ -59,12 +58,12 @@ jobs:
|
||||
lint-fix-python:
|
||||
if: github.event.label.name == 'lint-fix'
|
||||
name: Fix Python linting issues
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -72,7 +71,7 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
|
||||
+13
-14
@@ -19,35 +19,34 @@ jobs:
|
||||
# Run lint on the frontend code
|
||||
lint-frontend:
|
||||
name: Lint frontend
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
run: |
|
||||
cd frontend
|
||||
npm install --frozen-lockfile
|
||||
- name: Lint, TypeScript compilation, and translation checks
|
||||
run: |
|
||||
cd frontend
|
||||
npm run lint
|
||||
npm run make-i18n && npx tsc
|
||||
npm run make-i18n && tsc
|
||||
npm run check-translation-completeness
|
||||
|
||||
# Run lint on the python code
|
||||
lint-python:
|
||||
name: Lint python
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
@@ -58,13 +57,13 @@ jobs:
|
||||
|
||||
lint-enterprise-python:
|
||||
name: Lint enterprise python
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
|
||||
@@ -18,7 +18,7 @@ concurrency:
|
||||
jobs:
|
||||
check-version:
|
||||
name: Check if version has changed
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
current-version: ${{ steps.version-check.outputs.current-version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2 # Need previous commit to compare
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should-publish == 'true'
|
||||
defaults:
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
runs-on: "${{ inputs.runner || 'ubuntu-latest' }}"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
steps:
|
||||
- name: Download review trace artifact
|
||||
id: download-trace
|
||||
uses: dawidd6/action-download-artifact@v15
|
||||
uses: dawidd6/action-download-artifact@v6
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: pr-review-by-openhands.yml
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
# Run python tests on Linux
|
||||
test-on-linux:
|
||||
name: Python Tests on Linux
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
env:
|
||||
INSTALL_DOCKER: "0" # Set to '0' to skip Docker installation
|
||||
strategy:
|
||||
@@ -30,22 +30,20 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Install tmux
|
||||
run: sudo apt-get update && sudo apt-get install -y tmux
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: "22.x"
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
@@ -75,16 +73,16 @@ jobs:
|
||||
|
||||
test-enterprise:
|
||||
name: Enterprise Python Unit Tests
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
@@ -113,9 +111,9 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v6
|
||||
id: download
|
||||
with:
|
||||
pattern: coverage-*
|
||||
|
||||
@@ -17,14 +17,14 @@ on:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-22.04
|
||||
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli' and don't start with 'cloud-'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli'
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'app server')
|
||||
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli') && !startsWith(github.ref, 'refs/tags/cloud-'))
|
||||
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli'))
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
- name: Install Poetry
|
||||
|
||||
@@ -8,10 +8,10 @@ on:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: github.repository == 'OpenHands/OpenHands'
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
|
||||
@@ -19,10 +19,10 @@ concurrency:
|
||||
jobs:
|
||||
ui-build:
|
||||
name: Build openhands-ui
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: "openhands-ui/.bun-version"
|
||||
|
||||
@@ -36,42 +36,6 @@ then re-run the command to ensure it passes. Common issues include:
|
||||
- Be especially careful with `git reset --hard` after staging files, as it will remove accidentally staged files
|
||||
- When remote has new changes, use `git fetch upstream && git rebase upstream/<branch>` on the same branch
|
||||
|
||||
## Lockfile Regeneration (Preserve Original Tool Versions)
|
||||
|
||||
When regenerating lockfiles (poetry.lock, uv.lock, etc.), you MUST use the same tool version that originally generated the lockfile to avoid unnecessary diff noise. Each lockfile contains a version header indicating which tool version was used.
|
||||
|
||||
### Poetry (poetry.lock)
|
||||
|
||||
1. Extract the version from the lockfile header:
|
||||
```bash
|
||||
POETRY_VERSION=$(grep -m1 "^# This file is automatically @generated by Poetry" poetry.lock | sed 's/.*Poetry \([0-9.]*\).*/\1/')
|
||||
```
|
||||
2. If a version is found, install that specific version:
|
||||
```bash
|
||||
pipx install poetry==$POETRY_VERSION --force
|
||||
```
|
||||
3. Then regenerate the lockfile:
|
||||
```bash
|
||||
poetry lock --no-update
|
||||
```
|
||||
|
||||
### uv (uv.lock)
|
||||
|
||||
1. Extract the version from the lockfile header:
|
||||
```bash
|
||||
UV_VERSION=$(grep -m1 "^# This file was autogenerated by uv" uv.lock | sed 's/.*uv version \([0-9.]*\).*/\1/')
|
||||
```
|
||||
2. If a version is found, install that specific version:
|
||||
```bash
|
||||
pipx install uv==$UV_VERSION --force
|
||||
```
|
||||
3. Then regenerate the lockfile:
|
||||
```bash
|
||||
uv lock
|
||||
```
|
||||
|
||||
This ensures that lockfile updates only contain actual dependency changes, not tool version migration artifacts.
|
||||
|
||||
## PR-Specific Artifacts (`.pr/` directory)
|
||||
|
||||
When working on a PR that requires design documents, scripts meant for development-only, or other temporary artifacts that should NOT be merged to main, store them in a `.pr/` directory at the repository root.
|
||||
@@ -138,6 +102,8 @@ Frontend:
|
||||
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationSkills`)
|
||||
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
|
||||
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
|
||||
- SaaS usage dashboard settings live at `/settings/usage`; frontend access should be gated with `canAccessUsageDashboard(...)` / `createUsageDashboardGuard(...)` so personal workspaces are always allowed and org workspaces are limited to admin/owner roles.
|
||||
|
||||
|
||||
VSCode Extension:
|
||||
- Located in the `openhands/integrations/vscode` directory
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=pt">Português</a> |
|
||||
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=ru">Русский</a> |
|
||||
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=zh">中文</a>
|
||||
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
@@ -83,71 +84,3 @@ All our work is available under the MIT license, except for the `enterprise/` di
|
||||
The core `openhands` and `agent-server` Docker images are fully MIT-licensed as well.
|
||||
|
||||
If you need help with anything, or just want to chat, [come find us on Slack](https://dub.sh/openhands).
|
||||
|
||||
<hr>
|
||||
|
||||
### Thank You to Our Contributors
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/OpenHands/OpenHands/graphs/contributors)
|
||||
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
### Trusted by Engineers at
|
||||
|
||||
<div align="center">
|
||||
<br/><br/>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/tiktok.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/tiktok.svg" alt="TikTok" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/vmware.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/vmware.svg" alt="VMware" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/roche.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/roche.svg" alt="Roche" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/amazon.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/amazon.svg" alt="Amazon" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/c3-ai.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/c3-ai.svg" alt="C3 AI" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/netflix.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/netflix.svg" alt="Netflix" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mastercard.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/mastercard.svg" alt="Mastercard" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/red-hat.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/red-hat.svg" alt="Red Hat" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mongodb.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/mongodb.svg" alt="MongoDB" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/apple.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/apple.svg" alt="Apple" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/nvidia.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/nvidia.svg" alt="NVIDIA" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/google.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/google.svg" alt="Google" height="17" hspace="5">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,7 @@ RUN mkdir -p $FILE_STORE_PATH
|
||||
RUN mkdir -p $WORKSPACE_BASE
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y curl git ssh sudo \
|
||||
&& apt-get install -y curl ssh sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Default is 1000, but OSX is often 501
|
||||
@@ -73,17 +73,6 @@ ENV VIRTUAL_ENV=/app/.venv \
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
|
||||
# Pin pip to a known-good version (reproducible builds) and fix CVE-2025-8869
|
||||
# Pin both venv pip and system pip (Trivy scans both)
|
||||
# - `python -m pip` uses the venv because `PATH` is prefixed with `${VIRTUAL_ENV}/bin`
|
||||
# - `/usr/local/bin/python3 -m pip` uses the system interpreter regardless of `PATH`
|
||||
ARG PIP_VERSION=26.0.1
|
||||
RUN python -m pip install --no-cache-dir "pip==${PIP_VERSION}"
|
||||
|
||||
USER root
|
||||
RUN /usr/local/bin/python3 -m pip install --no-cache-dir "pip==${PIP_VERSION}" --break-system-packages
|
||||
USER openhands
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
|
||||
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
|
||||
+3
-8
@@ -8,17 +8,15 @@ push=0
|
||||
load=0
|
||||
tag_suffix=""
|
||||
dry_run=0
|
||||
platform_override=""
|
||||
|
||||
# Function to display usage information
|
||||
usage() {
|
||||
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [-p <platform>] [--dry]"
|
||||
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [--dry]"
|
||||
echo " -i: Image name (required)"
|
||||
echo " -o: Organization name"
|
||||
echo " --push: Push the image"
|
||||
echo " --load: Load the image"
|
||||
echo " -t: Tag suffix"
|
||||
echo " -p: Platform(s) to build for (e.g. linux/amd64 or linux/amd64,linux/arm64)"
|
||||
echo " --dry: Don't build, only create build-args.json"
|
||||
exit 1
|
||||
}
|
||||
@@ -31,7 +29,6 @@ while [[ $# -gt 0 ]]; do
|
||||
--push) push=1; shift ;;
|
||||
--load) load=1; shift ;;
|
||||
-t) tag_suffix="$2"; shift 2 ;;
|
||||
-p) platform_override="$2"; shift 2 ;;
|
||||
--dry) dry_run=1; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -137,10 +134,8 @@ fi
|
||||
|
||||
echo "Args: $args"
|
||||
|
||||
# Determine the platform(s) to build for
|
||||
if [[ -n "$platform_override" ]]; then
|
||||
platform="$platform_override"
|
||||
elif [[ $load -eq 1 ]]; then
|
||||
# Modify the platform selection based on --load flag
|
||||
if [[ $load -eq 1 ]]; then
|
||||
# When loading, build only for the current platform
|
||||
platform=$(docker version -f '{{.Server.Os}}/{{.Server.Arch}}')
|
||||
else
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.15.0-python}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -58,8 +58,6 @@ repos:
|
||||
types-Markdown,
|
||||
pydantic,
|
||||
lxml,
|
||||
"openhands-sdk==1.14",
|
||||
"openhands-tools==1.14",
|
||||
]
|
||||
# To see gaps add `--html-report mypy-report/`
|
||||
entry: mypy --config-file dev_config/python/mypy.ini openhands/
|
||||
|
||||
@@ -14,11 +14,3 @@ exclude = (third_party/|enterprise/)
|
||||
|
||||
[mypy-openhands.memory.condenser.impl.*]
|
||||
disable_error_code = override
|
||||
|
||||
[mypy-openai.*]
|
||||
follow_imports = skip
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-litellm.*]
|
||||
follow_imports = skip
|
||||
ignore_missing_imports = True
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ services:
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.15.0-python}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -723,13 +723,11 @@
|
||||
"https://$WEB_HOST/slack/keycloak-callback",
|
||||
"https://$WEB_HOST/oauth/device/keycloak-callback",
|
||||
"https://$WEB_HOST/api/email/verified",
|
||||
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*",
|
||||
"https://laminar.$WEB_HOST/api/auth/callback/keycloak"
|
||||
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"https://$WEB_HOST",
|
||||
"https://$AUTH_WEB_HOST",
|
||||
"https://laminar.$WEB_HOST"
|
||||
"https://$AUTH_WEB_HOST"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
|
||||
@@ -10,7 +10,6 @@ from integrations.github.github_types import (
|
||||
)
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
@@ -27,7 +26,6 @@ from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.org_store import OrgStore
|
||||
from storage.proactive_conversation_store import ProactiveConversationStore
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
@@ -43,14 +41,16 @@ from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import Comment
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.services.conversation_service import start_conversation
|
||||
from openhands.server.services.conversation_service import (
|
||||
initialize_conversation,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
@@ -154,17 +154,12 @@ class GithubIssue(ResolverViewInterface):
|
||||
return user_secrets.custom_secrets if user_secrets else None
|
||||
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# FIXME: Handle if initialize_conversation returns None
|
||||
|
||||
self.v1_enabled = await is_v1_enabled_for_github_resolver(
|
||||
self.user_info.keycloak_user_id
|
||||
)
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider='github',
|
||||
full_repo_name=self.full_repo_name,
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
|
||||
)
|
||||
@@ -178,28 +173,16 @@ class GithubIssue(ResolverViewInterface):
|
||||
selected_repository=self.full_repo_name,
|
||||
)
|
||||
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
self.user_info.keycloak_user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.RESOLVER,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
conversation_id=None,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
conversation_trigger=ConversationTrigger.RESOLVER,
|
||||
git_provider=ProviderType.GITHUB,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
self.conversation_id = conversation_metadata.conversation_id
|
||||
return conversation_metadata
|
||||
|
||||
async def create_new_conversation(
|
||||
@@ -311,10 +294,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
)
|
||||
|
||||
# Set up the GitHub user context for the V1 system
|
||||
github_user_context = ResolverUserContext(
|
||||
saas_user_auth=saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
@@ -342,7 +322,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
'full_repo_name': self.full_repo_name,
|
||||
'installation_id': self.installation_id,
|
||||
},
|
||||
should_request_summary=self.send_summary_instruction,
|
||||
send_summary_instruction=self.send_summary_instruction,
|
||||
)
|
||||
|
||||
|
||||
@@ -496,7 +476,7 @@ class GithubInlinePRComment(GithubPRComment):
|
||||
'comment_id': self.comment_id,
|
||||
},
|
||||
inline_pr_comment=True,
|
||||
should_request_summary=self.send_summary_instruction,
|
||||
send_summary_instruction=self.send_summary_instruction,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ from uuid import UUID, uuid4
|
||||
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_V1_GITLAB_RESOLVER,
|
||||
@@ -15,7 +14,6 @@ from integrations.utils import (
|
||||
from jinja2 import Environment
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
@@ -31,13 +29,15 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import Comment
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.services.conversation_service import start_conversation
|
||||
from openhands.server.services.conversation_service import (
|
||||
initialize_conversation,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
CONFIDENTIAL_NOTE = 'confidential_note'
|
||||
@@ -118,14 +118,6 @@ class GitlabIssue(ResolverViewInterface):
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# v1_enabled is already set at construction time in the factory method
|
||||
# This is the source of truth for the conversation type
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider='gitlab',
|
||||
full_repo_name=self.full_repo_name,
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
)
|
||||
|
||||
if self.v1_enabled:
|
||||
# Create dummy conversation metadata
|
||||
# Don't save to conversation store
|
||||
@@ -136,28 +128,16 @@ class GitlabIssue(ResolverViewInterface):
|
||||
selected_repository=self.full_repo_name,
|
||||
)
|
||||
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
self.user_info.keycloak_user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.RESOLVER,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
conversation_id=None,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
conversation_trigger=ConversationTrigger.RESOLVER,
|
||||
git_provider=ProviderType.GITLAB,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
self.conversation_id = conversation_metadata.conversation_id
|
||||
return conversation_metadata
|
||||
|
||||
async def create_new_conversation(
|
||||
@@ -248,10 +228,7 @@ class GitlabIssue(ResolverViewInterface):
|
||||
)
|
||||
|
||||
# Set up the GitLab user context for the V1 system
|
||||
gitlab_user_context = ResolverUserContext(
|
||||
saas_user_auth=saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
gitlab_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, gitlab_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
@@ -283,7 +260,7 @@ class GitlabIssue(ResolverViewInterface):
|
||||
'is_mr': self.is_mr,
|
||||
'discussion_id': getattr(self, 'discussion_id', None),
|
||||
},
|
||||
should_request_summary=self.send_summary_instruction,
|
||||
send_summary_instruction=self.send_summary_instruction,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,34 +1,25 @@
|
||||
from dataclasses import dataclass
|
||||
from uuid import uuid4
|
||||
|
||||
from integrations.linear.linear_types import LinearViewInterface, StartingConvoException
|
||||
from integrations.models import JobContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
|
||||
from jinja2 import Environment
|
||||
from server.config import get_config
|
||||
from storage.linear_conversation import LinearConversation
|
||||
from storage.linear_integration_store import LinearIntegrationStore
|
||||
from storage.linear_user import LinearUser
|
||||
from storage.linear_workspace import LinearWorkspace
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_conversation_settings,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
|
||||
integration_store = LinearIntegrationStore.get_instance()
|
||||
|
||||
@@ -70,70 +61,20 @@ class LinearNewConversationView(LinearViewInterface):
|
||||
instructions, user_msg = await self._get_instructions(jinja_env)
|
||||
|
||||
try:
|
||||
user_id = self.linear_user.keycloak_user_id
|
||||
|
||||
# Resolve git provider from repository
|
||||
resolved_git_provider = None
|
||||
if provider_tokens:
|
||||
try:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(
|
||||
self.selected_repo
|
||||
)
|
||||
resolved_git_provider = repository.git_provider
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Linear] Failed to resolve git provider for {self.selected_repo}: {e}'
|
||||
)
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
resolved_org_id = None
|
||||
if resolved_git_provider and self.selected_repo:
|
||||
try:
|
||||
resolved_org_id = await resolve_org_for_repo(
|
||||
provider=resolved_git_provider.value,
|
||||
full_repo_name=self.selected_repo,
|
||||
keycloak_user_id=user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Linear] Failed to resolve org for {self.selected_repo}: {e}'
|
||||
)
|
||||
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
user_id,
|
||||
resolved_org_id,
|
||||
)
|
||||
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.LINEAR,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=user_id,
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=self.linear_user.keycloak_user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
git_provider=resolved_git_provider,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
await start_conversation(
|
||||
user_id=user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
initial_user_msg=user_msg,
|
||||
conversation_instructions=instructions,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_id=conversation_id,
|
||||
conversation_metadata=conversation_metadata,
|
||||
conversation_instructions=instructions,
|
||||
conversation_trigger=ConversationTrigger.LINEAR,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
|
||||
logger.info(f'[Linear] Created conversation {self.conversation_id}')
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from uuid import UUID
|
||||
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
|
||||
from openhands.integrations.service_types import ProviderType, UserGitInfo
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.sdk.secret import SecretSource, StaticSecret
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
@@ -14,10 +12,8 @@ class ResolverUserContext(UserContext):
|
||||
def __init__(
|
||||
self,
|
||||
saas_user_auth: UserAuth,
|
||||
resolver_org_id: UUID | None = None,
|
||||
):
|
||||
self.saas_user_auth = saas_user_auth
|
||||
self.resolver_org_id = resolver_org_id
|
||||
self._provider_handler: ProviderHandler | None = None
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
@@ -85,6 +81,3 @@ class ResolverUserContext(UserContext):
|
||||
|
||||
async def get_mcp_api_key(self) -> str | None:
|
||||
return await self.saas_user_auth.get_mcp_api_key()
|
||||
|
||||
async def get_user_git_info(self) -> UserGitInfo | None:
|
||||
return await self.saas_user_auth.get_user_git_info()
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Resolve which OpenHands organization workspace a resolver conversation should be created in.
|
||||
|
||||
This module provides a reusable utility for routing resolver conversations
|
||||
(GitHub, GitLab, Bitbucket, Slack, etc.) to the correct OpenHands organization
|
||||
workspace based on claimed Git organizations.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from storage.org_git_claim_store import OrgGitClaimStore
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
async def resolve_org_for_repo(
|
||||
provider: str,
|
||||
full_repo_name: str,
|
||||
keycloak_user_id: str,
|
||||
) -> UUID | None:
|
||||
"""Determine the OpenHands org_id for a resolver conversation.
|
||||
|
||||
If the repo's git organization is claimed by an OpenHands org AND the user
|
||||
is a member of that org, returns the claiming org's ID. Otherwise returns
|
||||
None (caller should fall back to user.current_org_id / personal workspace).
|
||||
|
||||
Args:
|
||||
provider: Git provider name ("github", "gitlab", "bitbucket")
|
||||
full_repo_name: Full repository name (e.g., "OpenHands/foo")
|
||||
keycloak_user_id: The user's Keycloak UUID string
|
||||
|
||||
Returns:
|
||||
The org_id if the repo's org is claimed and user is a member, else None
|
||||
"""
|
||||
git_org = full_repo_name.split('/')[0].lower()
|
||||
|
||||
try:
|
||||
claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
|
||||
provider, git_org
|
||||
)
|
||||
if not claim:
|
||||
logger.debug(
|
||||
f'[OrgResolver] No claim found for {provider}/{git_org}',
|
||||
)
|
||||
return None
|
||||
|
||||
member = await OrgMemberStore.get_org_member(
|
||||
claim.org_id, UUID(keycloak_user_id)
|
||||
)
|
||||
if not member:
|
||||
logger.debug(
|
||||
f'[OrgResolver] User {keycloak_user_id} is not a member of org '
|
||||
f'{claim.org_id} (claimed {provider}/{git_org}). '
|
||||
f'Falling back to personal workspace.',
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f'[OrgResolver] Routing conversation to org {claim.org_id} '
|
||||
f'for {provider}/{git_org} (user {keycloak_user_id})',
|
||||
)
|
||||
return claim.org_id
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[OrgResolver] Error resolving org for {provider}/{git_org}: {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
@@ -239,14 +239,12 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
def _generate_repo_selection_form(
|
||||
self, message_ts: str, thread_ts: str | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Generate a repo selection form with immediate "No Repository" button and search dropdown.
|
||||
"""Generate a repo selection form using external_select for dynamic loading.
|
||||
|
||||
This form provides two options side-by-side:
|
||||
1. A "No Repository" button - immediately clickable without any loading
|
||||
2. An external_select dropdown - for searching repositories dynamically
|
||||
|
||||
This design ensures "No Repository" is always immediately available while
|
||||
still providing full dynamic search capability for repositories.
|
||||
This uses Slack's external_select element which allows:
|
||||
- Type-ahead search for repositories
|
||||
- Dynamic loading of options from an external endpoint
|
||||
- Support for users with many repositories (no 100 option limit)
|
||||
|
||||
Args:
|
||||
message_ts: The message timestamp for tracking
|
||||
@@ -268,22 +266,12 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
'type': 'section',
|
||||
'text': {
|
||||
'type': 'mrkdwn',
|
||||
'text': 'Select a repository or continue without one:',
|
||||
'text': 'Type to search your repositories:',
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'actions',
|
||||
'elements': [
|
||||
{
|
||||
'type': 'button',
|
||||
'action_id': f'no_repository:{message_ts}:{thread_ts}',
|
||||
'text': {
|
||||
'type': 'plain_text',
|
||||
'text': 'No Repository',
|
||||
'emoji': True,
|
||||
},
|
||||
'value': '-',
|
||||
},
|
||||
{
|
||||
'type': 'external_select',
|
||||
'action_id': f'repository_select:{message_ts}:{thread_ts}',
|
||||
@@ -291,8 +279,8 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
'type': 'plain_text',
|
||||
'text': 'Search repositories...',
|
||||
},
|
||||
'min_query_length': 0,
|
||||
},
|
||||
'min_query_length': 0, # Load initial options immediately
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -300,11 +288,8 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
def _build_repo_options(self, repos: list[Repository]) -> list[dict[str, Any]]:
|
||||
"""Build Slack options list from repositories.
|
||||
|
||||
Returns up to 100 repositories formatted as Slack options
|
||||
(Slack has a 100 option limit for external_select).
|
||||
|
||||
Note: "No Repository" is handled by a separate button in the form,
|
||||
so it's not included in the dropdown options.
|
||||
Always includes a "No Repository" option at the top, followed by up to 99
|
||||
repositories (Slack has a 100 option limit for external_select).
|
||||
|
||||
Args:
|
||||
repos: List of Repository objects
|
||||
@@ -312,7 +297,13 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
Returns:
|
||||
List of Slack option objects
|
||||
"""
|
||||
return [
|
||||
options: list[dict[str, Any]] = [
|
||||
{
|
||||
'text': {'type': 'plain_text', 'text': 'No Repository'},
|
||||
'value': '-',
|
||||
}
|
||||
]
|
||||
options.extend(
|
||||
{
|
||||
'text': {
|
||||
'type': 'plain_text',
|
||||
@@ -320,8 +311,9 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
},
|
||||
'value': repo.full_name,
|
||||
}
|
||||
for repo in repos[:100]
|
||||
]
|
||||
for repo in repos[:99] # Leave room for "No Repository" option
|
||||
)
|
||||
return options
|
||||
|
||||
async def search_repos_for_slack(
|
||||
self, user_auth: UserAuth, query: str, per_page: int = 20
|
||||
@@ -371,69 +363,33 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
SlackError(SlackErrorCode.UNEXPECTED_ERROR),
|
||||
)
|
||||
|
||||
def _parse_form_action(self, action: dict) -> tuple[str, str | None, str] | None:
|
||||
"""Parse action payload and extract message_ts, thread_ts, and selected value.
|
||||
|
||||
This handles the different payload structures for button clicks vs dropdown
|
||||
selections in the repository selection form.
|
||||
|
||||
Args:
|
||||
action: The action object from the Slack payload
|
||||
|
||||
Returns:
|
||||
Tuple of (message_ts, thread_ts, selected_value) if action is recognized,
|
||||
None if the action_id is unknown.
|
||||
"""
|
||||
action_id = action['action_id']
|
||||
|
||||
if action_id.startswith('no_repository:'):
|
||||
# Button click - value is in 'value' field
|
||||
attribs = action_id.split('no_repository:')[-1]
|
||||
selected_value = action.get('value', '-')
|
||||
elif action_id.startswith('repository_select:'):
|
||||
# Dropdown selection - value is in 'selected_option'
|
||||
attribs = action_id.split('repository_select:')[-1]
|
||||
selected_value = action['selected_option']['value']
|
||||
else:
|
||||
return None
|
||||
|
||||
message_ts, thread_ts = attribs.split(':')
|
||||
thread_ts = None if thread_ts == 'None' else thread_ts
|
||||
|
||||
return message_ts, thread_ts, selected_value
|
||||
|
||||
async def receive_form_interaction(self, slack_payload: dict):
|
||||
"""Process a Slack form interaction (repository selection or button click).
|
||||
"""Process a Slack form interaction (repository selection).
|
||||
|
||||
This handles the block_actions payload when a user interacts with the
|
||||
repository selection form. It can handle:
|
||||
- "No Repository" button click: proceeds with conversation without a repo
|
||||
- Repository selection from dropdown: proceeds with the selected repo
|
||||
This handles the block_actions payload when a user selects a repository
|
||||
from the dropdown form. It retrieves the original user message from Redis
|
||||
and delegates to receive_message for processing.
|
||||
|
||||
Args:
|
||||
slack_payload: The raw Slack interaction payload
|
||||
"""
|
||||
# Extract fields from the Slack interaction payload
|
||||
action = slack_payload['actions'][0]
|
||||
selected_repository = slack_payload['actions'][0]['selected_option']['value']
|
||||
if selected_repository == '-':
|
||||
selected_repository = None
|
||||
|
||||
slack_user_id = slack_payload['user']['id']
|
||||
channel_id = slack_payload['container']['channel_id']
|
||||
team_id = slack_payload['team']['id']
|
||||
|
||||
# Parse the action to extract message_ts, thread_ts, and selected value
|
||||
parsed = self._parse_form_action(action)
|
||||
if parsed is None:
|
||||
logger.warning(
|
||||
'slack_unknown_action_id',
|
||||
extra={
|
||||
'action_id': action['action_id'],
|
||||
'slack_user_id': slack_user_id,
|
||||
},
|
||||
)
|
||||
return
|
||||
# Get original message_ts and thread_ts from action_id
|
||||
attribs = slack_payload['actions'][0]['action_id'].split('repository_select:')[
|
||||
-1
|
||||
]
|
||||
message_ts, thread_ts = attribs.split(':')
|
||||
thread_ts = None if thread_ts == 'None' else thread_ts
|
||||
|
||||
message_ts, thread_ts, selected_value = parsed
|
||||
|
||||
# Build partial payload for error handling
|
||||
# Build partial payload for error handling during Redis retrieval
|
||||
payload = {
|
||||
'team_id': team_id,
|
||||
'channel_id': channel_id,
|
||||
@@ -442,9 +398,6 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
'thread_ts': thread_ts,
|
||||
}
|
||||
|
||||
# Convert "-" (No Repository) to None
|
||||
selected_repository = None if selected_value == '-' else selected_value
|
||||
|
||||
# Retrieve the original user message from Redis
|
||||
try:
|
||||
user_msg = await self._retrieve_user_msg_for_form(message_ts, thread_ts)
|
||||
|
||||
@@ -4,7 +4,6 @@ from uuid import UUID, uuid4
|
||||
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.slack.slack_types import (
|
||||
SlackMessageView,
|
||||
SlackViewInterface,
|
||||
@@ -18,9 +17,7 @@ from integrations.utils import (
|
||||
get_user_v1_enabled_setting,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
from server.config import get_config
|
||||
from slack_sdk import WebClient
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.slack_conversation import SlackConversation
|
||||
from storage.slack_conversation_store import SlackConversationStore
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
@@ -39,20 +36,18 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_conversation_settings,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
# =================================================
|
||||
# SECTION: Slack view types
|
||||
@@ -207,22 +202,6 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
user_secrets = await self.saas_user_auth.get_secrets()
|
||||
|
||||
# Determine git provider from repository (needed for both org routing and conversation creation)
|
||||
self._resolved_git_provider = None
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
self._resolved_git_provider = repository.git_provider
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = None
|
||||
if self._resolved_git_provider and self.selected_repo:
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider=self._resolved_git_provider.value,
|
||||
full_repo_name=self.selected_repo,
|
||||
keycloak_user_id=self.slack_to_openhands_user.keycloak_user_id,
|
||||
)
|
||||
|
||||
# Check if V1 conversations are enabled for this user
|
||||
self.v1_enabled = await is_v1_enabled_for_slack_resolver(
|
||||
self.slack_to_openhands_user.keycloak_user_id
|
||||
@@ -245,44 +224,30 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
jinja
|
||||
)
|
||||
|
||||
user_id = self.slack_to_openhands_user.keycloak_user_id
|
||||
# Determine git provider from repository
|
||||
git_provider = None
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
git_provider = repository.git_provider
|
||||
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.SLACK,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=user_id,
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=self.slack_to_openhands_user.keycloak_user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
git_provider=self._resolved_git_provider,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
await start_conversation(
|
||||
user_id=user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
initial_user_msg=user_instructions,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_id=conversation_id,
|
||||
conversation_metadata=conversation_metadata,
|
||||
conversation_instructions=(
|
||||
conversation_instructions if conversation_instructions else None
|
||||
),
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_trigger=ConversationTrigger.SLACK,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
git_provider=git_provider,
|
||||
)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
|
||||
await self.save_slack_convo(v1_enabled=False)
|
||||
|
||||
@@ -300,8 +265,13 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
# Create the Slack V1 callback processor
|
||||
slack_callback_processor = self._create_slack_v1_callback_processor()
|
||||
|
||||
# Use git provider resolved in create_or_update_conversation
|
||||
git_provider = self._resolved_git_provider
|
||||
# Determine git provider from repository
|
||||
git_provider = None
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
git_provider = ProviderType(repository.git_provider.value)
|
||||
|
||||
# Get the app conversation service and start the conversation
|
||||
injector_state = InjectorState()
|
||||
@@ -322,10 +292,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
)
|
||||
|
||||
# Set up the Slack user context for the V1 system
|
||||
slack_user_context = ResolverUserContext(
|
||||
saas_user_auth=self.saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
|
||||
@@ -8,7 +8,7 @@ logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
|
||||
|
||||
from alembic import context # noqa: E402
|
||||
from google.cloud.sql.connector import Connector # noqa: E402
|
||||
from sqlalchemy import create_engine, text # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from storage.base import Base # noqa: E402
|
||||
|
||||
target_metadata = Base.metadata
|
||||
@@ -109,10 +109,6 @@ def run_migrations_online() -> None:
|
||||
version_table_schema=target_metadata.schema,
|
||||
)
|
||||
|
||||
# Lock number must be unique — md5 hash of 'openhands_enterprise_migrations'
|
||||
# Lock is released when the connection context manager exits
|
||||
connection.execute(text('SELECT pg_advisory_lock(3617572382373537863)'))
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ Create Date: 2026-03-26
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -34,7 +33,7 @@ def upgrade() -> None:
|
||||
sa.text(
|
||||
'UPDATE org_member SET mcp_config = :config WHERE org_id = :org_id'
|
||||
),
|
||||
{'config': json.dumps(mcp_config), 'org_id': str(org_id)},
|
||||
{'config': mcp_config, 'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Add disabled_skills column to user table.
|
||||
|
||||
Migration 102 added disabled_skills to the legacy user_settings table,
|
||||
but the active SaaS flow (SaasSettingsStore) reads from/writes to the
|
||||
user table. This migration adds the column where it is actually needed.
|
||||
|
||||
Revision ID: 104
|
||||
Revises: 103
|
||||
Create Date: 2026-03-31
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '104'
|
||||
down_revision: Union[str, None] = '103'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('user', sa.Column('disabled_skills', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user', 'disabled_skills')
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Create org_git_claim table for tracking Git organization claims.
|
||||
|
||||
Revision ID: 105
|
||||
Revises: 104
|
||||
Create Date: 2026-04-01
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '105'
|
||||
down_revision: Union[str, None] = '104'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'org_git_claim',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('org_id', sa.UUID(), nullable=False),
|
||||
sa.Column('provider', sa.String(), nullable=False),
|
||||
sa.Column('git_organization', sa.String(), nullable=False),
|
||||
sa.Column('claimed_by', sa.UUID(), nullable=False),
|
||||
sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['org_id'], ['org.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['claimed_by'], ['user.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('provider', 'git_organization', name='uq_provider_git_org'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('org_git_claim')
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Add tags column to conversation_metadata table.
|
||||
|
||||
Tags store key-value pairs for automation context (trigger type, automation_id),
|
||||
skills used, and other metadata. This enables querying conversations by
|
||||
automation source and associating SDK-provided context with conversations.
|
||||
|
||||
Revision ID: 106
|
||||
Revises: 105
|
||||
Create Date: 2026-03-31
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '106'
|
||||
down_revision: Union[str, None] = '105'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
sa.Column('tags', sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('conversation_metadata', 'tags')
|
||||
Generated
+2275
-2526
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ from storage.role import Role
|
||||
from storage.role_store import RoleStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
|
||||
class Permission(str, Enum):
|
||||
@@ -84,9 +84,6 @@ class Permission(str, Enum):
|
||||
# Temporary permissions until we finish the API updates.
|
||||
EDIT_ORG_SETTINGS = 'edit_org_settings'
|
||||
|
||||
# Git organization claims
|
||||
MANAGE_ORG_CLAIMS = 'manage_org_claims'
|
||||
|
||||
|
||||
class RoleName(str, Enum):
|
||||
"""Role names used in the system."""
|
||||
@@ -121,8 +118,6 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
|
||||
# Organization Management (Owner only)
|
||||
Permission.CHANGE_ORGANIZATION_NAME,
|
||||
Permission.DELETE_ORGANIZATION,
|
||||
# Git organization claims
|
||||
Permission.MANAGE_ORG_CLAIMS,
|
||||
]
|
||||
),
|
||||
RoleName.ADMIN: frozenset(
|
||||
@@ -144,8 +139,6 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
|
||||
# Organization Management
|
||||
Permission.VIEW_ORG_SETTINGS,
|
||||
Permission.EDIT_ORG_SETTINGS,
|
||||
# Git organization claims
|
||||
Permission.MANAGE_ORG_CLAIMS,
|
||||
]
|
||||
),
|
||||
RoleName.MEMBER: frozenset(
|
||||
@@ -318,96 +311,3 @@ def require_permission(permission: Permission):
|
||||
return user_id
|
||||
|
||||
return permission_checker
|
||||
|
||||
|
||||
async def require_financial_data_access(
|
||||
request: Request,
|
||||
org_id: UUID,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> str:
|
||||
"""
|
||||
Authorization dependency for accessing organization financial data.
|
||||
|
||||
Allows access if ANY of these conditions are met:
|
||||
1. User has Admin or Owner role in the organization
|
||||
2. User has @openhands.dev email domain
|
||||
|
||||
This is used for the organization members financial data endpoint.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
org_id: Organization UUID from path parameter
|
||||
user_id: User ID from authentication
|
||||
|
||||
Returns:
|
||||
str: User ID if authorized
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated, 403 if not authorized
|
||||
"""
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User not authenticated',
|
||||
)
|
||||
|
||||
# Validate API key organization binding
|
||||
api_key_org_id = await get_api_key_org_id_from_request(request)
|
||||
if api_key_org_id is not None:
|
||||
if api_key_org_id != org_id:
|
||||
logger.warning(
|
||||
'API key organization mismatch for financial data access',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'api_key_org_id': str(api_key_org_id),
|
||||
'target_org_id': str(org_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='API key is not authorized for this organization',
|
||||
)
|
||||
|
||||
# Check if user has @openhands.dev email
|
||||
user_auth = await get_user_auth(request)
|
||||
user_email = await user_auth.get_user_email()
|
||||
|
||||
if user_email and user_email.endswith('@openhands.dev'):
|
||||
logger.debug(
|
||||
'Financial data access granted via @openhands.dev email',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
return user_id
|
||||
|
||||
# Check if user has Admin or Owner role in the organization
|
||||
user_role = await get_user_org_role(user_id, org_id)
|
||||
|
||||
if not user_role:
|
||||
logger.warning(
|
||||
'Financial data access denied - user not a member of organization',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User is not a member of this organization',
|
||||
)
|
||||
|
||||
if user_role.name not in (RoleName.OWNER.value, RoleName.ADMIN.value):
|
||||
logger.warning(
|
||||
'Financial data access denied - insufficient role',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'user_role': user_role.name,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Access restricted to organization admins, owners, or OpenHands members',
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
'Financial data access granted via admin/owner role',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'role': user_role.name},
|
||||
)
|
||||
return user_id
|
||||
|
||||
@@ -6,6 +6,7 @@ GITHUB_APP_WEBHOOK_SECRET = os.getenv('GITHUB_APP_WEBHOOK_SECRET', '')
|
||||
GITHUB_APP_PRIVATE_KEY = os.getenv('GITHUB_APP_PRIVATE_KEY', '').replace('\\n', '\n')
|
||||
KEYCLOAK_SERVER_URL = os.getenv('KEYCLOAK_SERVER_URL', '').rstrip('/')
|
||||
KEYCLOAK_REALM_NAME = os.getenv('KEYCLOAK_REALM_NAME', '')
|
||||
KEYCLOAK_PROVIDER_NAME = os.getenv('KEYCLOAK_PROVIDER_NAME', '')
|
||||
KEYCLOAK_CLIENT_ID = os.getenv('KEYCLOAK_CLIENT_ID', '')
|
||||
KEYCLOAK_CLIENT_SECRET = os.getenv('KEYCLOAK_CLIENT_SECRET', '')
|
||||
KEYCLOAK_SERVER_URL_EXT = os.getenv(
|
||||
|
||||
@@ -4,6 +4,7 @@ from server.auth.constants import (
|
||||
KEYCLOAK_ADMIN_PASSWORD,
|
||||
KEYCLOAK_CLIENT_ID,
|
||||
KEYCLOAK_CLIENT_SECRET,
|
||||
KEYCLOAK_PROVIDER_NAME,
|
||||
KEYCLOAK_REALM_NAME,
|
||||
KEYCLOAK_SERVER_URL,
|
||||
KEYCLOAK_SERVER_URL_EXT,
|
||||
@@ -11,7 +12,7 @@ from server.auth.constants import (
|
||||
from server.logger import logger
|
||||
|
||||
logger.debug(
|
||||
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
|
||||
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_PROVIDER_NAME:{KEYCLOAK_PROVIDER_NAME}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
|
||||
)
|
||||
|
||||
_keycloak_instances = {}
|
||||
|
||||
@@ -35,7 +35,6 @@ from openhands.integrations.provider import (
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.server.types import SessionExpiredError
|
||||
from openhands.server.user_auth.user_auth import AuthType, UserAuth
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
@@ -314,18 +313,6 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
|
||||
)
|
||||
await saas_user_auth.refresh()
|
||||
return saas_user_auth
|
||||
except KeycloakError as exc:
|
||||
# Check for session expiration errors from Keycloak
|
||||
error_str = str(exc)
|
||||
if 'invalid_grant' in error_str or 'session not found' in error_str.lower():
|
||||
logger.warning(
|
||||
'API key authentication failed due to expired session',
|
||||
extra={'error': error_str},
|
||||
)
|
||||
raise SessionExpiredError(
|
||||
'Your session has expired. Please log in at https://app.all-hands.dev to re-authenticate, then retry your request.'
|
||||
) from exc
|
||||
raise BearerTokenError from exc
|
||||
except Exception as exc:
|
||||
raise BearerTokenError from exc
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ def setup_json_logger(
|
||||
handler.setLevel(level)
|
||||
|
||||
formatter = JsonFormatter(
|
||||
'%(message)s%(levelname)s%(module)s%(funcName)s%(lineno)d',
|
||||
'{message}{levelname}',
|
||||
style='{',
|
||||
rename_fields={'levelname': 'severity'},
|
||||
json_serializer=custom_json_serializer,
|
||||
# Use 'ts' for consistency with LOG_JSON_FOR_CONSOLE mode (skip when console mode to avoid duplicates)
|
||||
|
||||
@@ -16,7 +16,6 @@ from server.routes.auth import set_response_cookie
|
||||
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.types import SessionExpiredError
|
||||
from openhands.server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
|
||||
from openhands.server.utils import config
|
||||
|
||||
@@ -77,16 +76,6 @@ class SetAuthCookieMiddleware:
|
||||
return JSONResponse(
|
||||
{'error': str(e) or e.__class__.__name__}, status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
except SessionExpiredError as e:
|
||||
logger.info('session_expired', extra={'message': str(e)})
|
||||
# Return a helpful error message explaining how to fix the issue
|
||||
return JSONResponse(
|
||||
{
|
||||
'error': 'SessionExpired',
|
||||
'message': str(e),
|
||||
},
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
except AuthError as e:
|
||||
logger.warning('auth_error', exc_info=True)
|
||||
try:
|
||||
|
||||
@@ -7,8 +7,8 @@ from storage.database import a_session_maker
|
||||
from storage.feedback import ConversationFeedback
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.shared import file_store
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
|
||||
@@ -335,9 +335,6 @@ async def on_options_load(request: Request, background_tasks: BackgroundTasks):
|
||||
2. Searches for repositories matching the user's query
|
||||
3. Returns up to 100 options for the dropdown
|
||||
|
||||
Note: "No Repository" is handled by a separate button in the form, so it's
|
||||
not included in the dropdown options. Error cases return an empty list.
|
||||
|
||||
Configuration: Set the Options Load URL in Slack App settings to:
|
||||
https://your-domain/slack/on-options-load
|
||||
"""
|
||||
|
||||
@@ -120,18 +120,3 @@ class BatchInvitationResponse(BaseModel):
|
||||
|
||||
successful: list[InvitationResponse]
|
||||
failed: list[InvitationFailure]
|
||||
|
||||
|
||||
class AcceptInvitationRequest(BaseModel):
|
||||
"""Request model for accepting an invitation via POST."""
|
||||
|
||||
token: str
|
||||
|
||||
|
||||
class AcceptInvitationResponse(BaseModel):
|
||||
"""Response model for successful invitation acceptance."""
|
||||
|
||||
success: bool
|
||||
org_id: str
|
||||
org_name: str
|
||||
role: str
|
||||
|
||||
@@ -5,8 +5,6 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from server.routes.org_invitation_models import (
|
||||
AcceptInvitationRequest,
|
||||
AcceptInvitationResponse,
|
||||
BatchInvitationResponse,
|
||||
EmailMismatchError,
|
||||
InsufficientPermissionError,
|
||||
@@ -19,11 +17,10 @@ from server.routes.org_invitation_models import (
|
||||
)
|
||||
from server.services.org_invitation_service import OrgInvitationService
|
||||
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from storage.org_store import OrgStore
|
||||
from storage.role_store import RoleStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
|
||||
# Router for invitation operations on an organization (requires org_id)
|
||||
invitation_router = APIRouter(prefix='/api/organizations/{org_id}/members')
|
||||
@@ -126,93 +123,70 @@ async def create_invitation(
|
||||
|
||||
|
||||
@accept_router.get('/accept')
|
||||
async def accept_invitation_redirect(
|
||||
async def accept_invitation(
|
||||
token: str,
|
||||
request: Request,
|
||||
):
|
||||
"""Redirect invitation acceptance to frontend.
|
||||
"""Accept an organization invitation via token.
|
||||
|
||||
This endpoint is accessed via the link in the invitation email.
|
||||
It always redirects to the home page with the invitation token,
|
||||
allowing the frontend to handle the acceptance flow via a modal.
|
||||
|
||||
This approach works with SameSite='strict' cookies because:
|
||||
- Cross-site navigation (clicking email link) doesn't send cookies
|
||||
- But same-origin POST requests (from frontend) DO send cookies
|
||||
Flow:
|
||||
1. If user is authenticated: Accept invitation directly and redirect to home
|
||||
2. If user is not authenticated: Redirect to login page with invitation token
|
||||
- Frontend stores token and includes it in OAuth state during login
|
||||
- After authentication, keycloak_callback processes the invitation
|
||||
|
||||
Args:
|
||||
token: The invitation token from the email link
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
RedirectResponse: Redirect to home page with invitation_token query param
|
||||
RedirectResponse: Redirect to home page on success, or login page if not authenticated,
|
||||
or home page with error query params on failure
|
||||
"""
|
||||
base_url = str(request.base_url).rstrip('/')
|
||||
|
||||
logger.info(
|
||||
'Invitation accept: redirecting to frontend for acceptance',
|
||||
extra={'token_prefix': token[:10] + '...'},
|
||||
)
|
||||
|
||||
return RedirectResponse(f'{base_url}/?invitation_token={token}', status_code=302)
|
||||
|
||||
|
||||
@accept_router.post('/accept', response_model=AcceptInvitationResponse)
|
||||
async def accept_invitation(
|
||||
request_data: AcceptInvitationRequest,
|
||||
user_id: str = Depends(get_user_id),
|
||||
):
|
||||
"""Accept an organization invitation via authenticated POST request.
|
||||
|
||||
This endpoint is called by the frontend after displaying the acceptance modal.
|
||||
Requires authentication - cookies are sent because this is a same-origin request.
|
||||
|
||||
Args:
|
||||
request_data: Contains the invitation token
|
||||
user_id: Authenticated user ID (from dependency)
|
||||
|
||||
Returns:
|
||||
AcceptInvitationResponse: Success response with organization details
|
||||
|
||||
Raises:
|
||||
HTTPException 400: Invalid or expired token
|
||||
HTTPException 403: Email mismatch
|
||||
HTTPException 409: User already a member
|
||||
"""
|
||||
token = request_data.token
|
||||
|
||||
# Try to get user_id from auth (may not be authenticated)
|
||||
user_id = None
|
||||
try:
|
||||
invitation = await OrgInvitationService.accept_invitation(token, UUID(user_id))
|
||||
user_auth = await get_user_auth(request)
|
||||
if user_auth:
|
||||
user_id = await user_auth.get_user_id()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get organization and role details for response
|
||||
org = await OrgStore.get_org_by_id(invitation.org_id)
|
||||
role = await RoleStore.get_role_by_id(invitation.role_id)
|
||||
if not user_id:
|
||||
# User not authenticated - redirect to login page with invitation token
|
||||
# Frontend will store the token and include it in OAuth state during login
|
||||
logger.info(
|
||||
'Invitation accept: redirecting unauthenticated user to login',
|
||||
extra={'token_prefix': token[:10] + '...'},
|
||||
)
|
||||
login_url = f'{base_url}/login?invitation_token={token}'
|
||||
return RedirectResponse(login_url, status_code=302)
|
||||
|
||||
# User is authenticated - process the invitation directly
|
||||
try:
|
||||
await OrgInvitationService.accept_invitation(token, UUID(user_id))
|
||||
|
||||
logger.info(
|
||||
'Invitation accepted via API',
|
||||
'Invitation accepted successfully',
|
||||
extra={
|
||||
'token_prefix': token[:10] + '...',
|
||||
'user_id': user_id,
|
||||
'org_id': str(invitation.org_id),
|
||||
},
|
||||
)
|
||||
|
||||
return AcceptInvitationResponse(
|
||||
success=True,
|
||||
org_id=str(invitation.org_id),
|
||||
org_name=org.name if org else '',
|
||||
role=role.name if role else '',
|
||||
)
|
||||
# Redirect to home page on success
|
||||
return RedirectResponse(f'{base_url}/', status_code=302)
|
||||
|
||||
except InvitationExpiredError:
|
||||
logger.warning(
|
||||
'Invitation accept failed: expired',
|
||||
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='invitation_expired',
|
||||
)
|
||||
return RedirectResponse(f'{base_url}/?invitation_expired=true', status_code=302)
|
||||
|
||||
except InvitationInvalidError as e:
|
||||
logger.warning(
|
||||
@@ -223,20 +197,14 @@ async def accept_invitation(
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='invitation_invalid',
|
||||
)
|
||||
return RedirectResponse(f'{base_url}/?invitation_invalid=true', status_code=302)
|
||||
|
||||
except UserAlreadyMemberError:
|
||||
logger.info(
|
||||
'Invitation accept: user already member',
|
||||
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='already_member',
|
||||
)
|
||||
return RedirectResponse(f'{base_url}/?already_member=true', status_code=302)
|
||||
|
||||
except EmailMismatchError as e:
|
||||
logger.warning(
|
||||
@@ -247,21 +215,15 @@ async def accept_invitation(
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='email_mismatch',
|
||||
)
|
||||
return RedirectResponse(f'{base_url}/?email_mismatch=true', status_code=302)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error accepting invitation via API',
|
||||
'Unexpected error accepting invitation',
|
||||
extra={
|
||||
'token_prefix': token[:10] + '...',
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
return RedirectResponse(f'{base_url}/?invitation_error=true', status_code=302)
|
||||
|
||||
@@ -485,70 +485,30 @@ class OrgAppSettingsUpdate(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
VALID_GIT_PROVIDERS = {'github', 'gitlab', 'bitbucket'}
|
||||
class UsageDashboardSummary(BaseModel):
|
||||
"""Summary metrics for the usage dashboard."""
|
||||
|
||||
average_cost_per_conversation_last_30_days: float = 0.0
|
||||
total_conversations: int = 0
|
||||
|
||||
|
||||
class GitOrgClaimRequest(BaseModel):
|
||||
"""Request model for claiming a Git organization."""
|
||||
class UsageDashboardDailyConversationCount(BaseModel):
|
||||
"""Daily conversation count for the usage dashboard trend chart."""
|
||||
|
||||
provider: str
|
||||
git_organization: str
|
||||
|
||||
@field_validator('provider')
|
||||
@classmethod
|
||||
def validate_provider(cls, v: str) -> str:
|
||||
v = v.lower().strip()
|
||||
if v not in VALID_GIT_PROVIDERS:
|
||||
raise ValueError(
|
||||
f'Invalid provider: "{v}". Must be one of: {", ".join(sorted(VALID_GIT_PROVIDERS))}'
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator('git_organization')
|
||||
@classmethod
|
||||
def validate_git_organization(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if not v:
|
||||
raise ValueError('git_organization must not be empty')
|
||||
return v
|
||||
date: str
|
||||
conversation_count: int
|
||||
|
||||
|
||||
class GitOrgClaimResponse(BaseModel):
|
||||
"""Response model for a Git organization claim."""
|
||||
class UsageDashboardRepositoryCount(BaseModel):
|
||||
"""Conversation counts grouped by repository."""
|
||||
|
||||
id: str
|
||||
org_id: str
|
||||
provider: str
|
||||
git_organization: str
|
||||
claimed_by: str
|
||||
claimed_at: str
|
||||
repository: str
|
||||
conversation_count: int
|
||||
|
||||
|
||||
class GitOrgAlreadyClaimedError(Exception):
|
||||
"""Raised when a Git organization is already claimed by another OpenHands org."""
|
||||
class OrgUsageDashboardResponse(BaseModel):
|
||||
"""Response payload for the organization usage dashboard."""
|
||||
|
||||
def __init__(self, provider: str, git_organization: str):
|
||||
self.provider = provider
|
||||
self.git_organization = git_organization
|
||||
super().__init__(
|
||||
f'Git organization "{git_organization}" on {provider} is already claimed by another organization'
|
||||
)
|
||||
|
||||
|
||||
class OrgMemberFinancialResponse(BaseModel):
|
||||
"""Financial data for a single organization member."""
|
||||
|
||||
user_id: str
|
||||
email: str | None
|
||||
lifetime_spend: float # Total amount spent (from LiteLLM)
|
||||
current_budget: float # Remaining budget (max_budget - spend)
|
||||
max_budget: float | None # Total allocated budget (None = unlimited)
|
||||
|
||||
|
||||
class OrgMemberFinancialPage(BaseModel):
|
||||
"""Paginated response for organization member financial data."""
|
||||
|
||||
items: list[OrgMemberFinancialResponse]
|
||||
current_page: int = 1
|
||||
per_page: int = 10
|
||||
next_page_id: str | None = None
|
||||
summary: UsageDashboardSummary
|
||||
daily_conversations: list[UsageDashboardDailyConversationCount]
|
||||
top_repositories: list[UsageDashboardRepositoryCount]
|
||||
|
||||
@@ -4,15 +4,11 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from server.auth.authorization import (
|
||||
Permission,
|
||||
require_financial_data_access,
|
||||
require_permission,
|
||||
)
|
||||
from server.email_validation import get_admin_user_id
|
||||
from server.routes.org_models import (
|
||||
CannotModifySelfError,
|
||||
GitOrgAlreadyClaimedError,
|
||||
GitOrgClaimRequest,
|
||||
GitOrgClaimResponse,
|
||||
InsufficientPermissionError,
|
||||
InvalidRoleError,
|
||||
LastOwnerError,
|
||||
@@ -26,7 +22,6 @@ from server.routes.org_models import (
|
||||
OrgDatabaseError,
|
||||
OrgLLMSettingsResponse,
|
||||
OrgLLMSettingsUpdate,
|
||||
OrgMemberFinancialPage,
|
||||
OrgMemberNotFoundError,
|
||||
OrgMemberPage,
|
||||
OrgMemberResponse,
|
||||
@@ -36,6 +31,7 @@ from server.routes.org_models import (
|
||||
OrgPage,
|
||||
OrgResponse,
|
||||
OrgUpdate,
|
||||
OrgUsageDashboardResponse,
|
||||
OrphanedUserError,
|
||||
RoleNotFoundError,
|
||||
)
|
||||
@@ -47,13 +43,15 @@ from server.services.org_llm_settings_service import (
|
||||
OrgLLMSettingsService,
|
||||
OrgLLMSettingsServiceInjector,
|
||||
)
|
||||
from server.services.org_member_financial_service import OrgMemberFinancialService
|
||||
from server.services.org_member_service import OrgMemberService
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from storage.org_git_claim_store import OrgGitClaimStore
|
||||
from server.services.org_usage_service import (
|
||||
OrgUsageService,
|
||||
OrgUsageServiceInjector,
|
||||
)
|
||||
from storage.org_service import OrgService
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.errors import AuthError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
@@ -66,6 +64,8 @@ org_llm_settings_service_dependency = Depends(_org_llm_settings_injector.depends
|
||||
# Create injector instance and dependency at module level
|
||||
_org_app_settings_injector = OrgAppSettingsServiceInjector()
|
||||
org_app_settings_service_dependency = Depends(_org_app_settings_injector.depends)
|
||||
_org_usage_injector = OrgUsageServiceInjector()
|
||||
org_usage_service_dependency = Depends(_org_usage_injector.depends)
|
||||
|
||||
|
||||
@org_router.get('', response_model=OrgPage)
|
||||
@@ -417,6 +417,40 @@ async def update_org_app_settings(
|
||||
)
|
||||
|
||||
|
||||
@org_router.get('/{org_id}/usage', response_model=OrgUsageDashboardResponse)
|
||||
async def get_org_usage_dashboard(
|
||||
org_id: UUID,
|
||||
service: OrgUsageService = org_usage_service_dependency,
|
||||
) -> OrgUsageDashboardResponse:
|
||||
"""Get usage dashboard metrics for an organization."""
|
||||
try:
|
||||
return await service.get_org_usage(org_id)
|
||||
except AuthError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except OrgAuthorizationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error retrieving organization usage dashboard',
|
||||
extra={'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve usage dashboard',
|
||||
)
|
||||
|
||||
|
||||
@org_router.get('/{org_id}', response_model=OrgResponse, status_code=status.HTTP_200_OK)
|
||||
async def get_org(
|
||||
org_id: UUID,
|
||||
@@ -891,104 +925,6 @@ async def get_org_members_count(
|
||||
)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
'/{org_id}/members/financial',
|
||||
response_model=OrgMemberFinancialPage,
|
||||
)
|
||||
async def get_org_members_financial(
|
||||
org_id: UUID,
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
title='Pagination offset encoded as string',
|
||||
description='Offset for pagination (e.g., "0", "10", "20")',
|
||||
),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(
|
||||
title='Maximum items per page',
|
||||
gt=0,
|
||||
le=100,
|
||||
),
|
||||
] = 10,
|
||||
email: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
title='Filter members by email (case-insensitive partial match)',
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
),
|
||||
] = None,
|
||||
user_id: str = Depends(require_financial_data_access),
|
||||
) -> OrgMemberFinancialPage:
|
||||
"""Get paginated financial data for organization members.
|
||||
|
||||
Returns financial information (lifetime spend, current budget) for all members
|
||||
within the specified organization. Access is restricted to:
|
||||
- Organization Admins
|
||||
- Organization Owners
|
||||
- OpenHands members (users with @openhands.dev emails)
|
||||
|
||||
Args:
|
||||
org_id: Organization ID (UUID)
|
||||
page_id: Optional pagination offset encoded as string
|
||||
limit: Maximum items per page (1-100, default 10)
|
||||
email: Optional email filter (case-insensitive partial match)
|
||||
user_id: Authenticated user ID (injected by require_financial_data_access)
|
||||
|
||||
Returns:
|
||||
OrgMemberFinancialPage: Paginated response with member financial data
|
||||
- items: List of members with user_id, email, lifetime_spend,
|
||||
current_budget, and max_budget
|
||||
- current_page: Current page number (1-indexed)
|
||||
- per_page: Items per page
|
||||
- next_page_id: Offset for next page, or None if no more pages
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 403 if user lacks access (not admin/owner and not @openhands.dev)
|
||||
HTTPException: 400 if page_id is invalid
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
logger.info(
|
||||
'Getting financial data for organization members',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'user_id': user_id,
|
||||
'page_id': page_id,
|
||||
'limit': limit,
|
||||
'email_filter': email,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
return await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
email_filter=email,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
'Invalid page_id for financial data request',
|
||||
extra={'org_id': str(org_id), 'page_id': page_id, 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'Error retrieving organization member financial data',
|
||||
extra={'org_id': str(org_id)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve member financial data',
|
||||
)
|
||||
|
||||
|
||||
@org_router.delete('/{org_id}/members/{user_id}')
|
||||
async def remove_org_member(
|
||||
org_id: UUID,
|
||||
@@ -1217,181 +1153,3 @@ async def update_org_member(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update member',
|
||||
)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
'/{org_id}/git-claims',
|
||||
response_model=list[GitOrgClaimResponse],
|
||||
)
|
||||
async def get_git_claims(
|
||||
org_id: UUID,
|
||||
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
|
||||
) -> list[GitOrgClaimResponse]:
|
||||
"""Get all Git organization claims for an OpenHands organization.
|
||||
|
||||
Only admin and owner roles can view Git organization claims.
|
||||
|
||||
Args:
|
||||
org_id: OpenHands organization UUID
|
||||
user_id: Authenticated user ID (injected by permission check)
|
||||
|
||||
Returns:
|
||||
List of GitOrgClaimResponse with claim details
|
||||
"""
|
||||
try:
|
||||
claims = await OrgGitClaimStore.get_claims_by_org_id(org_id=org_id)
|
||||
return [
|
||||
GitOrgClaimResponse(
|
||||
id=str(claim.id),
|
||||
org_id=str(claim.org_id),
|
||||
provider=claim.provider,
|
||||
git_organization=claim.git_organization,
|
||||
claimed_by=str(claim.claimed_by),
|
||||
claimed_at=claim.claimed_at.isoformat(),
|
||||
)
|
||||
for claim in claims
|
||||
]
|
||||
except Exception:
|
||||
logger.exception('Error fetching Git organization claims')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to fetch Git organization claims',
|
||||
)
|
||||
|
||||
|
||||
@org_router.post(
|
||||
'/{org_id}/git-claims',
|
||||
response_model=GitOrgClaimResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def claim_git_organization(
|
||||
org_id: UUID,
|
||||
request: GitOrgClaimRequest,
|
||||
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
|
||||
) -> GitOrgClaimResponse:
|
||||
"""Claim a Git organization for an OpenHands organization.
|
||||
|
||||
Only admin and owner roles can claim Git organizations.
|
||||
A Git organization can only be claimed by one OpenHands organization at a time.
|
||||
|
||||
Args:
|
||||
org_id: OpenHands organization UUID
|
||||
request: Claim request with provider and git_organization
|
||||
user_id: Authenticated user ID (injected by permission check)
|
||||
|
||||
Returns:
|
||||
GitOrgClaimResponse with the created claim details
|
||||
|
||||
Raises:
|
||||
HTTPException 409: If the Git organization is already claimed
|
||||
HTTPException 403: If user lacks permission
|
||||
"""
|
||||
try:
|
||||
# Check if this Git org is already claimed (early feedback for the common case)
|
||||
existing_claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
|
||||
provider=request.provider,
|
||||
git_organization=request.git_organization,
|
||||
)
|
||||
|
||||
if existing_claim:
|
||||
raise GitOrgAlreadyClaimedError(
|
||||
provider=request.provider,
|
||||
git_organization=request.git_organization,
|
||||
)
|
||||
|
||||
# Create the claim — the DB unique constraint handles the race condition
|
||||
# where two concurrent requests both pass the check above.
|
||||
claim = await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider=request.provider,
|
||||
git_organization=request.git_organization,
|
||||
claimed_by=UUID(user_id),
|
||||
)
|
||||
|
||||
return GitOrgClaimResponse(
|
||||
id=str(claim.id),
|
||||
org_id=str(claim.org_id),
|
||||
provider=claim.provider,
|
||||
git_organization=claim.git_organization,
|
||||
claimed_by=str(claim.claimed_by),
|
||||
claimed_at=claim.claimed_at.isoformat(),
|
||||
)
|
||||
|
||||
except GitOrgAlreadyClaimedError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(e),
|
||||
)
|
||||
except IntegrityError as e:
|
||||
# Only treat the unique constraint violation as a duplicate claim.
|
||||
# Other integrity errors (e.g. FK violations) should surface as 500s.
|
||||
if 'uq_provider_git_org' in str(e.orig):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(
|
||||
GitOrgAlreadyClaimedError(
|
||||
provider=request.provider,
|
||||
git_organization=request.git_organization,
|
||||
)
|
||||
),
|
||||
)
|
||||
logger.exception('Integrity error claiming Git organization')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to claim Git organization',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Error claiming Git organization')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to claim Git organization',
|
||||
)
|
||||
|
||||
|
||||
@org_router.delete(
|
||||
'/{org_id}/git-claims/{claim_id}',
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def disconnect_git_organization(
|
||||
org_id: UUID,
|
||||
claim_id: UUID,
|
||||
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
|
||||
) -> dict:
|
||||
"""Remove a Git organization claim from an OpenHands organization.
|
||||
|
||||
Only admin and owner roles can disconnect Git organization claims.
|
||||
|
||||
Args:
|
||||
org_id: OpenHands organization UUID
|
||||
claim_id: Claim UUID to remove
|
||||
user_id: Authenticated user ID (injected by permission check)
|
||||
|
||||
Returns:
|
||||
dict: Confirmation message on successful deletion
|
||||
|
||||
Raises:
|
||||
HTTPException 404: If the claim is not found for this organization
|
||||
HTTPException 403: If user lacks permission
|
||||
"""
|
||||
try:
|
||||
deleted = await OrgGitClaimStore.delete_claim(
|
||||
claim_id=claim_id,
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Git organization claim not found',
|
||||
)
|
||||
|
||||
return {'message': 'Git organization claim removed successfully'}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Error disconnecting Git organization')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to disconnect Git organization',
|
||||
)
|
||||
|
||||
@@ -7,10 +7,8 @@ from server.auth.token_manager import TokenManager
|
||||
from storage.user_store import UserStore
|
||||
from utils.identity import resolve_display_name
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
)
|
||||
from openhands.integrations.service_types import (
|
||||
Branch,
|
||||
@@ -24,6 +22,7 @@ from openhands.microagent.types import (
|
||||
MicroagentContentResponse,
|
||||
MicroagentResponse,
|
||||
)
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.routes.git import (
|
||||
get_repository_branches,
|
||||
get_repository_microagent_content,
|
||||
@@ -45,12 +44,7 @@ saas_user_router = APIRouter(prefix='/api/user', dependencies=get_dependencies()
|
||||
token_manager = TokenManager()
|
||||
|
||||
|
||||
@saas_user_router.get(
|
||||
'/installations',
|
||||
response_model=list[str],
|
||||
deprecated=True,
|
||||
description='Deprecated: Use `/api/v1/git/installations` instead.',
|
||||
)
|
||||
@saas_user_router.get('/installations', response_model=list[str])
|
||||
async def saas_get_user_installations(
|
||||
provider: ProviderType,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
@@ -73,59 +67,7 @@ async def saas_get_user_installations(
|
||||
)
|
||||
|
||||
|
||||
@saas_user_router.get('/git-organizations')
|
||||
async def saas_get_user_git_organizations(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if not provider_tokens:
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value={},
|
||||
)
|
||||
if retval is not None:
|
||||
return retval
|
||||
# _check_idp returned None (tokens refreshed on Keycloak side),
|
||||
# but provider_tokens is still None for this request.
|
||||
return JSONResponse(
|
||||
content='Git provider token required.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
# SaaS users sign in with one provider at a time
|
||||
provider = next(iter(provider_tokens))
|
||||
|
||||
if provider == ProviderType.GITHUB:
|
||||
orgs = await client.get_github_organizations()
|
||||
elif provider == ProviderType.GITLAB:
|
||||
orgs = await client.get_gitlab_groups()
|
||||
elif provider == ProviderType.BITBUCKET:
|
||||
orgs = await client.get_bitbucket_workspaces()
|
||||
else:
|
||||
return JSONResponse(
|
||||
content=f"Provider {provider.value} doesn't support git organizations",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return {
|
||||
'provider': provider.value,
|
||||
'organizations': orgs,
|
||||
}
|
||||
|
||||
|
||||
@saas_user_router.get(
|
||||
'/repositories',
|
||||
response_model=list[Repository],
|
||||
deprecated=True,
|
||||
description='Deprecated: Use `/api/v1/git/repositories` instead.',
|
||||
)
|
||||
@saas_user_router.get('/repositories', response_model=list[Repository])
|
||||
async def saas_get_user_repositories(
|
||||
sort: str = 'pushed',
|
||||
selected_provider: ProviderType | None = None,
|
||||
@@ -156,13 +98,12 @@ async def saas_get_user_repositories(
|
||||
)
|
||||
|
||||
|
||||
@saas_user_router.get('/info', response_model=User, deprecated=True)
|
||||
@saas_user_router.get('/info', response_model=User)
|
||||
async def saas_get_user(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> User | JSONResponse:
|
||||
"""Get the current user git info. Use GET /api/v1/users/git-info instead"""
|
||||
if not provider_tokens:
|
||||
if not access_token:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
"""Service for managing organization member financial data."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from server.routes.org_models import (
|
||||
OrgMemberFinancialPage,
|
||||
OrgMemberFinancialResponse,
|
||||
)
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class OrgMemberFinancialService:
|
||||
"""Service for organization member financial data operations."""
|
||||
|
||||
@staticmethod
|
||||
async def get_org_members_financial_data(
|
||||
org_id: UUID,
|
||||
page_id: str | None = None,
|
||||
limit: int = 10,
|
||||
email_filter: str | None = None,
|
||||
) -> OrgMemberFinancialPage:
|
||||
"""Get paginated financial data for organization members.
|
||||
|
||||
Fetches member list from database and joins with financial data from LiteLLM.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID
|
||||
page_id: Offset encoded as string (e.g., "0", "10", "20")
|
||||
limit: Maximum items per page (default 10)
|
||||
email_filter: Optional case-insensitive partial email match
|
||||
|
||||
Returns:
|
||||
OrgMemberFinancialPage: Paginated response with financial data
|
||||
|
||||
Raises:
|
||||
ValueError: If page_id is invalid
|
||||
"""
|
||||
# Parse page_id to get offset
|
||||
offset = 0
|
||||
if page_id is not None:
|
||||
try:
|
||||
offset = int(page_id)
|
||||
if offset < 0:
|
||||
raise ValueError('page_id must be non-negative')
|
||||
except ValueError as e:
|
||||
raise ValueError(f'Invalid page_id: {page_id}') from e
|
||||
|
||||
# Fetch paginated members from database
|
||||
members, total_count = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
email_filter=email_filter,
|
||||
)
|
||||
|
||||
if not members:
|
||||
return OrgMemberFinancialPage(
|
||||
items=[],
|
||||
current_page=(offset // limit) + 1,
|
||||
per_page=limit,
|
||||
next_page_id=None,
|
||||
)
|
||||
|
||||
# Fetch financial data from LiteLLM for the entire team
|
||||
# This is a single API call that returns all team members' data
|
||||
try:
|
||||
financial_data = await LiteLlmManager.get_team_members_financial_data(
|
||||
str(org_id)
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Re-raise auth errors - these indicate configuration issues that need fixing
|
||||
if e.response.status_code in (401, 403):
|
||||
logger.error(
|
||||
'LiteLLM authentication/authorization failed',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'status_code': e.response.status_code,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise
|
||||
# For other HTTP errors (404, 500, etc.), use graceful degradation
|
||||
logger.warning(
|
||||
'Failed to fetch financial data from LiteLLM',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'status_code': e.response.status_code,
|
||||
'error_type': type(e).__name__,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
financial_data = {}
|
||||
except Exception as e:
|
||||
# For network errors, timeouts, etc., use graceful degradation
|
||||
logger.warning(
|
||||
'Failed to fetch financial data from LiteLLM',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'error_type': type(e).__name__,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
financial_data = {}
|
||||
|
||||
# Extract team-level data for shared budget calculation
|
||||
team_spend = financial_data.get('team_spend', 0) or 0
|
||||
members_financial = financial_data.get('members', {})
|
||||
|
||||
# Build response items by joining DB members with LiteLLM financial data
|
||||
items: list[OrgMemberFinancialResponse] = []
|
||||
for member in members:
|
||||
user = member.user
|
||||
user_id_str = str(member.user_id)
|
||||
|
||||
# Get financial data for this user (or defaults if not found)
|
||||
user_financial = members_financial.get(user_id_str, {})
|
||||
individual_spend = user_financial.get('spend', 0) or 0
|
||||
max_budget = user_financial.get('max_budget')
|
||||
uses_shared_budget = user_financial.get('uses_shared_budget', False)
|
||||
|
||||
# Calculate current budget (remaining)
|
||||
# For shared team budgets, use team_spend to calculate remaining budget
|
||||
# This ensures all members see the same remaining budget
|
||||
if max_budget is not None:
|
||||
if uses_shared_budget:
|
||||
# Shared budget - use team's total spend
|
||||
current_budget = max(max_budget - team_spend, 0)
|
||||
else:
|
||||
# Individual budget - use individual spend
|
||||
current_budget = max(max_budget - individual_spend, 0)
|
||||
else:
|
||||
# If no max_budget, current_budget is unlimited (represented as 0)
|
||||
current_budget = 0
|
||||
|
||||
items.append(
|
||||
OrgMemberFinancialResponse(
|
||||
user_id=user_id_str,
|
||||
email=user.email if user else None,
|
||||
lifetime_spend=individual_spend,
|
||||
current_budget=current_budget,
|
||||
max_budget=max_budget,
|
||||
)
|
||||
)
|
||||
|
||||
# Calculate current page (1-indexed)
|
||||
current_page = (offset // limit) + 1
|
||||
|
||||
# Calculate next_page_id
|
||||
next_offset = offset + limit
|
||||
next_page_id = str(next_offset) if next_offset < total_count else None
|
||||
|
||||
logger.debug(
|
||||
'OrgMemberFinancialService:get_org_members_financial_data:success',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'items_count': len(items),
|
||||
'current_page': current_page,
|
||||
'total_count': total_count,
|
||||
},
|
||||
)
|
||||
|
||||
return OrgMemberFinancialPage(
|
||||
items=items,
|
||||
current_page=current_page,
|
||||
per_page=limit,
|
||||
next_page_id=next_page_id,
|
||||
)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Service for organization usage dashboard metrics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, time, timedelta
|
||||
from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from server.routes.org_models import (
|
||||
OrgAuthorizationError,
|
||||
OrgNotFoundError,
|
||||
OrgUsageDashboardResponse,
|
||||
UsageDashboardDailyConversationCount,
|
||||
UsageDashboardRepositoryCount,
|
||||
UsageDashboardSummary,
|
||||
)
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.app_server.errors import AuthError
|
||||
from openhands.app_server.services.injector import Injector, InjectorState
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
USAGE_WINDOW_DAYS = 30
|
||||
NO_REPOSITORY_LABEL = 'No repository'
|
||||
TEAM_USAGE_ALLOWED_ROLES = {'admin', 'owner'}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgUsageService:
|
||||
"""Service that aggregates org-scoped usage metrics."""
|
||||
|
||||
db_session: AsyncSession
|
||||
user_context: UserContext
|
||||
|
||||
async def get_org_usage(self, org_id: UUID) -> OrgUsageDashboardResponse:
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if not user_id:
|
||||
raise AuthError('User not authenticated')
|
||||
|
||||
logger.info(
|
||||
'Getting organization usage dashboard',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
org = await self._get_org(org_id)
|
||||
if not org:
|
||||
raise OrgNotFoundError(str(org_id))
|
||||
|
||||
role_name = await self._get_user_role_name(org_id=org_id, user_id=UUID(user_id))
|
||||
if role_name is None:
|
||||
raise OrgAuthorizationError('User is not a member of this organization')
|
||||
|
||||
if not self._can_access_usage_dashboard(
|
||||
org=org, role_name=role_name, user_id=user_id
|
||||
):
|
||||
raise OrgAuthorizationError(
|
||||
'Only organization admins and owners can view usage for team workspaces'
|
||||
)
|
||||
|
||||
total_conversations = await self._get_total_conversation_count(org_id)
|
||||
window_start = self._get_window_start()
|
||||
recent_rows = await self._get_recent_conversations(org_id, window_start)
|
||||
top_repositories = await self._get_top_repositories(org_id)
|
||||
|
||||
summary = UsageDashboardSummary(
|
||||
average_cost_per_conversation_last_30_days=self._calculate_average_cost(
|
||||
recent_rows
|
||||
),
|
||||
total_conversations=total_conversations,
|
||||
)
|
||||
|
||||
return OrgUsageDashboardResponse(
|
||||
summary=summary,
|
||||
daily_conversations=self._build_daily_conversation_counts(recent_rows),
|
||||
top_repositories=top_repositories,
|
||||
)
|
||||
|
||||
async def _get_org(self, org_id: UUID) -> Org | None:
|
||||
result = await self.db_session.execute(select(Org).where(Org.id == org_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_user_role_name(self, org_id: UUID, user_id: UUID) -> str | None:
|
||||
result = await self.db_session.execute(
|
||||
select(Role.name)
|
||||
.join(OrgMember, OrgMember.role_id == Role.id)
|
||||
.where(OrgMember.org_id == org_id, OrgMember.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def _can_access_usage_dashboard(
|
||||
self, org: Org, role_name: str, user_id: str
|
||||
) -> bool:
|
||||
is_personal_workspace = str(org.id) == user_id
|
||||
return is_personal_workspace or role_name in TEAM_USAGE_ALLOWED_ROLES
|
||||
|
||||
async def _get_total_conversation_count(self, org_id: UUID) -> int:
|
||||
query = (
|
||||
select(func.count(StoredConversationMetadata.conversation_id))
|
||||
.select_from(StoredConversationMetadata)
|
||||
.join(
|
||||
StoredConversationMetadataSaas,
|
||||
StoredConversationMetadata.conversation_id
|
||||
== StoredConversationMetadataSaas.conversation_id,
|
||||
)
|
||||
.where(
|
||||
StoredConversationMetadataSaas.org_id == org_id,
|
||||
StoredConversationMetadata.conversation_version == 'V1',
|
||||
)
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
count = result.scalar_one_or_none()
|
||||
return count or 0
|
||||
|
||||
async def _get_recent_conversations(
|
||||
self, org_id: UUID, window_start: datetime
|
||||
) -> list[tuple[datetime, float]]:
|
||||
query = (
|
||||
select(
|
||||
StoredConversationMetadata.created_at,
|
||||
StoredConversationMetadata.accumulated_cost,
|
||||
)
|
||||
.join(
|
||||
StoredConversationMetadataSaas,
|
||||
StoredConversationMetadata.conversation_id
|
||||
== StoredConversationMetadataSaas.conversation_id,
|
||||
)
|
||||
.where(
|
||||
StoredConversationMetadataSaas.org_id == org_id,
|
||||
StoredConversationMetadata.conversation_version == 'V1',
|
||||
StoredConversationMetadata.created_at >= window_start,
|
||||
)
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
return [
|
||||
(self._ensure_utc(created_at), accumulated_cost or 0.0)
|
||||
for created_at, accumulated_cost in result.all()
|
||||
]
|
||||
|
||||
async def _get_top_repositories(
|
||||
self, org_id: UUID
|
||||
) -> list[UsageDashboardRepositoryCount]:
|
||||
normalized_repository = case(
|
||||
(
|
||||
func.trim(
|
||||
func.coalesce(StoredConversationMetadata.selected_repository, '')
|
||||
)
|
||||
== '',
|
||||
NO_REPOSITORY_LABEL,
|
||||
),
|
||||
else_=StoredConversationMetadata.selected_repository,
|
||||
).label('repository')
|
||||
|
||||
query = (
|
||||
select(
|
||||
normalized_repository,
|
||||
func.count(StoredConversationMetadata.conversation_id).label(
|
||||
'conversation_count'
|
||||
),
|
||||
)
|
||||
.join(
|
||||
StoredConversationMetadataSaas,
|
||||
StoredConversationMetadata.conversation_id
|
||||
== StoredConversationMetadataSaas.conversation_id,
|
||||
)
|
||||
.where(
|
||||
StoredConversationMetadataSaas.org_id == org_id,
|
||||
StoredConversationMetadata.conversation_version == 'V1',
|
||||
)
|
||||
.group_by(normalized_repository)
|
||||
.order_by(
|
||||
func.count(StoredConversationMetadata.conversation_id).desc(),
|
||||
normalized_repository.asc(),
|
||||
)
|
||||
.limit(5)
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
return [
|
||||
UsageDashboardRepositoryCount(
|
||||
repository=repository,
|
||||
conversation_count=conversation_count,
|
||||
)
|
||||
for repository, conversation_count in result.all()
|
||||
]
|
||||
|
||||
def _calculate_average_cost(
|
||||
self, recent_rows: list[tuple[datetime, float]]
|
||||
) -> float:
|
||||
if not recent_rows:
|
||||
return 0.0
|
||||
total_cost = sum(cost for _, cost in recent_rows)
|
||||
return total_cost / len(recent_rows)
|
||||
|
||||
def _build_daily_conversation_counts(
|
||||
self, recent_rows: list[tuple[datetime, float]]
|
||||
) -> list[UsageDashboardDailyConversationCount]:
|
||||
start_date = self._get_window_start().date()
|
||||
today = datetime.now(UTC).date()
|
||||
counts_by_day = {
|
||||
start_date + timedelta(days=offset): 0
|
||||
for offset in range((today - start_date).days + 1)
|
||||
}
|
||||
|
||||
for created_at, _ in recent_rows:
|
||||
conversation_date = created_at.astimezone(UTC).date()
|
||||
if conversation_date in counts_by_day:
|
||||
counts_by_day[conversation_date] += 1
|
||||
|
||||
return [
|
||||
UsageDashboardDailyConversationCount(
|
||||
date=bucket_date.isoformat(),
|
||||
conversation_count=counts_by_day[bucket_date],
|
||||
)
|
||||
for bucket_date in sorted(counts_by_day)
|
||||
]
|
||||
|
||||
def _get_window_start(self) -> datetime:
|
||||
today = datetime.now(UTC).date()
|
||||
start_date = today - timedelta(days=USAGE_WINDOW_DAYS - 1)
|
||||
return datetime.combine(start_date, time.min, tzinfo=UTC)
|
||||
|
||||
def _ensure_utc(self, value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
class OrgUsageServiceInjector(Injector[OrgUsageService]):
|
||||
"""Injector for the organization usage service."""
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[OrgUsageService, None]:
|
||||
from openhands.app_server.config import get_db_session, get_user_context
|
||||
|
||||
async with (
|
||||
get_user_context(state, request) as user_context,
|
||||
get_db_session(state, request) as db_session,
|
||||
):
|
||||
yield OrgUsageService(db_session=db_session, user_context=user_context)
|
||||
@@ -363,11 +363,6 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
if api_key_org_id is not None:
|
||||
org_id = api_key_org_id
|
||||
|
||||
# Override with resolver org_id if set (from git org claim resolution)
|
||||
resolver_org_id = getattr(self.user_context, 'resolver_org_id', None)
|
||||
if resolver_org_id is not None:
|
||||
org_id = resolver_org_id
|
||||
|
||||
# Check if SAAS metadata already exists
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(info.id)
|
||||
|
||||
@@ -29,10 +29,7 @@ def get_cookie_domain() -> str | None:
|
||||
|
||||
|
||||
def get_cookie_samesite() -> Literal['lax', 'strict']:
|
||||
# Use 'strict' in production for maximum CSRF protection
|
||||
# Use 'lax' for local development and staging environments
|
||||
# Note: For invitation links from emails, the frontend handles acceptance via
|
||||
# an authenticated POST request (same-origin), which works with 'strict' cookies
|
||||
# for localhost and feature/staging stacks we set it to 'lax' as the cookie domain won't allow 'strict'
|
||||
web_url = get_global_config().web_url
|
||||
return (
|
||||
'strict'
|
||||
|
||||
@@ -17,7 +17,7 @@ from server.verified_models.verified_model_service import (
|
||||
|
||||
from openhands.app_server.config import get_db_session
|
||||
from openhands.server.routes import public
|
||||
from openhands.utils.llm import ModelsResponse, get_supported_llm_models
|
||||
from openhands.utils.llm import get_supported_llm_models
|
||||
|
||||
api_router = APIRouter(prefix='/api/admin/verified-models', tags=['Verified Models'])
|
||||
|
||||
@@ -117,7 +117,7 @@ async def delete_verified_model(
|
||||
)
|
||||
|
||||
|
||||
async def get_saas_llm_models_dependency(request: Request) -> ModelsResponse:
|
||||
async def get_saas_llm_models_dependency(request: Request) -> list[str]:
|
||||
"""SaaS implementation for the LLM models endpoint."""
|
||||
async with get_db_session(request.state, request) as db_session:
|
||||
# Prevent circular import
|
||||
|
||||
@@ -19,7 +19,6 @@ from storage.linear_workspace import LinearWorkspace
|
||||
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
|
||||
from storage.openhands_pr import OpenhandsPR
|
||||
from storage.org import Org
|
||||
from storage.org_git_claim import OrgGitClaim
|
||||
from storage.org_invitation import OrgInvitation
|
||||
from storage.org_member import OrgMember
|
||||
from storage.proactive_convos import ProactiveConversation
|
||||
@@ -66,7 +65,6 @@ __all__ = [
|
||||
'MaintenanceTaskStatus',
|
||||
'OpenhandsPR',
|
||||
'Org',
|
||||
'OrgGitClaim',
|
||||
'OrgInvitation',
|
||||
'OrgMember',
|
||||
'ProactiveConversation',
|
||||
|
||||
@@ -1524,83 +1524,6 @@ class LiteLlmManager:
|
||||
'LiteLlmManager:_delete_key:key_deleted',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _get_team_members_financial_data(
|
||||
client: httpx.AsyncClient,
|
||||
team_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Get financial data for all members in a team.
|
||||
|
||||
Fetches team info from LiteLLM and extracts spending/budget data for each member.
|
||||
|
||||
Args:
|
||||
client: HTTP client for LiteLLM API
|
||||
team_id: The team/organization ID
|
||||
|
||||
Returns:
|
||||
Dict with structure:
|
||||
{
|
||||
"team_max_budget": float | None, # Team's shared budget
|
||||
"team_spend": float, # Team's total spend (for shared budget calc)
|
||||
"members": {
|
||||
user_id: {
|
||||
"spend": float,
|
||||
"max_budget": float | None,
|
||||
"uses_shared_budget": bool # True if using team budget
|
||||
},
|
||||
...
|
||||
}
|
||||
}
|
||||
Returns empty dict if team not found or LiteLLM is not configured.
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return {}
|
||||
|
||||
team_info = await LiteLlmManager._get_team(client, team_id)
|
||||
if not team_info:
|
||||
logger.warning(
|
||||
'LiteLlmManager:_get_team_members_financial_data:team_not_found',
|
||||
extra={'team_id': team_id},
|
||||
)
|
||||
return {}
|
||||
|
||||
members: dict[str, dict] = {}
|
||||
team_memberships = team_info.get('team_memberships', [])
|
||||
|
||||
# Get team-level budget info (shared across all members in team orgs)
|
||||
team_data = team_info.get('team_info', {})
|
||||
team_max_budget = team_data.get('max_budget')
|
||||
team_spend = team_data.get('spend', 0) or 0
|
||||
|
||||
for membership in team_memberships:
|
||||
user_id = membership.get('user_id')
|
||||
if not user_id:
|
||||
continue
|
||||
|
||||
# Use individual max_budget_in_team if set, otherwise fall back to team budget
|
||||
member_max_budget = membership.get('max_budget_in_team')
|
||||
uses_shared_budget = member_max_budget is None
|
||||
if uses_shared_budget:
|
||||
member_max_budget = team_max_budget
|
||||
|
||||
members[user_id] = {
|
||||
'spend': membership.get('spend', 0) or 0,
|
||||
'max_budget': member_max_budget,
|
||||
'uses_shared_budget': uses_shared_budget,
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'LiteLlmManager:_get_team_members_financial_data:success',
|
||||
extra={'team_id': team_id, 'member_count': len(members)},
|
||||
)
|
||||
return {
|
||||
'team_max_budget': team_max_budget,
|
||||
'team_spend': team_spend,
|
||||
'members': members,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def with_http_client(
|
||||
internal_fn: Callable[..., Awaitable[Any]],
|
||||
@@ -1636,6 +1559,3 @@ class LiteLlmManager:
|
||||
get_user_keys = staticmethod(with_http_client(_get_user_keys))
|
||||
delete_key_by_alias = staticmethod(with_http_client(_delete_key_by_alias))
|
||||
update_user_keys = staticmethod(with_http_client(_update_user_keys))
|
||||
get_team_members_financial_data = staticmethod(
|
||||
with_http_client(_get_team_members_financial_data)
|
||||
)
|
||||
|
||||
@@ -64,7 +64,6 @@ class Org(Base): # type: ignore
|
||||
slack_conversations = relationship('SlackConversation', back_populates='org')
|
||||
slack_users = relationship('SlackUser', back_populates='org')
|
||||
stripe_customers = relationship('StripeCustomer', back_populates='org')
|
||||
git_claims = relationship('OrgGitClaim', back_populates='org')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# Handle known SQLAlchemy columns directly
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""
|
||||
SQLAlchemy model for Git Organization Claims.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import UUID, Column, DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class OrgGitClaim(Base): # type: ignore
|
||||
"""Model for tracking which OpenHands org has claimed a Git organization."""
|
||||
|
||||
__tablename__ = 'org_git_claim'
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||
org_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey('org.id', ondelete='CASCADE'), nullable=False
|
||||
)
|
||||
provider = Column(String, nullable=False)
|
||||
git_organization = Column(String, nullable=False)
|
||||
claimed_by = Column(UUID(as_uuid=True), ForeignKey('user.id'), nullable=False)
|
||||
claimed_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('provider', 'git_organization', name='uq_provider_git_org'),
|
||||
)
|
||||
|
||||
org = relationship('Org', back_populates='git_claims')
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
Store class for managing Git organization claims.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from storage.database import a_session_maker
|
||||
from storage.org_git_claim import OrgGitClaim
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class OrgGitClaimStore:
|
||||
"""Store for managing Git organization claims."""
|
||||
|
||||
@staticmethod
|
||||
async def create_claim(
|
||||
org_id: UUID,
|
||||
provider: str,
|
||||
git_organization: str,
|
||||
claimed_by: UUID,
|
||||
) -> OrgGitClaim:
|
||||
"""Create a new Git organization claim.
|
||||
|
||||
Args:
|
||||
org_id: OpenHands organization UUID
|
||||
provider: Git provider ('github', 'gitlab', 'bitbucket')
|
||||
git_organization: Name of the Git organization being claimed
|
||||
claimed_by: User UUID who is making the claim
|
||||
|
||||
Returns:
|
||||
OrgGitClaim: The created claim record
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
claim = OrgGitClaim(
|
||||
org_id=org_id,
|
||||
provider=provider,
|
||||
git_organization=git_organization,
|
||||
claimed_by=claimed_by,
|
||||
claimed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(claim)
|
||||
await session.commit()
|
||||
await session.refresh(claim)
|
||||
|
||||
logger.info(
|
||||
'Created Git organization claim',
|
||||
extra={
|
||||
'claim_id': str(claim.id),
|
||||
'org_id': str(org_id),
|
||||
'provider': provider,
|
||||
'git_organization': git_organization,
|
||||
'claimed_by': str(claimed_by),
|
||||
},
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
@staticmethod
|
||||
async def get_claim_by_provider_and_git_org(
|
||||
provider: str,
|
||||
git_organization: str,
|
||||
) -> Optional[OrgGitClaim]:
|
||||
"""Check if a Git organization is already claimed.
|
||||
|
||||
Args:
|
||||
provider: Git provider name
|
||||
git_organization: Name of the Git organization
|
||||
|
||||
Returns:
|
||||
OrgGitClaim or None if not claimed
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(OrgGitClaim).filter(
|
||||
and_(
|
||||
OrgGitClaim.provider == provider,
|
||||
OrgGitClaim.git_organization == git_organization,
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@staticmethod
|
||||
async def get_claims_by_org_id(org_id: UUID) -> list[OrgGitClaim]:
|
||||
"""Get all Git organization claims for an OpenHands organization.
|
||||
|
||||
Args:
|
||||
org_id: OpenHands organization UUID
|
||||
|
||||
Returns:
|
||||
List of OrgGitClaim records
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(OrgGitClaim).filter(OrgGitClaim.org_id == org_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@staticmethod
|
||||
async def delete_claim(claim_id: UUID, org_id: UUID) -> bool:
|
||||
"""Delete a Git organization claim.
|
||||
|
||||
Args:
|
||||
claim_id: Claim UUID to delete
|
||||
org_id: OpenHands organization UUID (for ownership verification)
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(OrgGitClaim).filter(
|
||||
and_(
|
||||
OrgGitClaim.id == claim_id,
|
||||
OrgGitClaim.org_id == org_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
claim = result.scalars().first()
|
||||
|
||||
if not claim:
|
||||
return False
|
||||
|
||||
await session.delete(claim)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
'Deleted Git organization claim',
|
||||
extra={
|
||||
'claim_id': str(claim_id),
|
||||
'org_id': str(org_id),
|
||||
'provider': claim.provider,
|
||||
'git_organization': claim.git_organization,
|
||||
},
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -34,17 +34,10 @@ class SaasConversationStore(ConversationStore):
|
||||
session_maker: sessionmaker
|
||||
org_id: UUID | None = None # will be fetched automatically
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
session_maker: sessionmaker,
|
||||
resolver_org_id: UUID | None = None,
|
||||
):
|
||||
def __init__(self, user_id: str, org_id: UUID, session_maker: sessionmaker):
|
||||
self.user_id = user_id
|
||||
self.org_id = org_id
|
||||
self.session_maker = session_maker
|
||||
self.resolver_org_id = resolver_org_id
|
||||
|
||||
def _select_by_id(self, session, conversation_id: str):
|
||||
# Join StoredConversationMetadata with ConversationMetadataSaas to filter by user/org
|
||||
@@ -110,13 +103,6 @@ class SaasConversationStore(ConversationStore):
|
||||
|
||||
stored_metadata = StoredConversationMetadata(**kwargs)
|
||||
|
||||
# Override with resolver org_id if set (from git org claim resolution),
|
||||
# same pattern as V1's save_app_conversation_info in
|
||||
# saas_app_conversation_info_injector.py
|
||||
org_id = self.org_id
|
||||
if self.resolver_org_id is not None:
|
||||
org_id = self.resolver_org_id
|
||||
|
||||
def _save_metadata():
|
||||
with self.session_maker() as session:
|
||||
# Save the main conversation metadata
|
||||
@@ -136,13 +122,13 @@ class SaasConversationStore(ConversationStore):
|
||||
saas_metadata = StoredConversationMetadataSaas(
|
||||
conversation_id=stored_metadata.conversation_id,
|
||||
user_id=UUID(self.user_id),
|
||||
org_id=org_id,
|
||||
org_id=self.org_id,
|
||||
)
|
||||
session.add(saas_metadata)
|
||||
else:
|
||||
# Validate
|
||||
expected_user_id = UUID(self.user_id)
|
||||
expected_org_id = org_id
|
||||
expected_org_id = self.org_id
|
||||
|
||||
if saas_metadata.user_id != expected_user_id:
|
||||
raise ValueError(
|
||||
@@ -254,19 +240,3 @@ class SaasConversationStore(ConversationStore):
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
org_id = user.current_org_id if user else None
|
||||
return SaasConversationStore(user_id, org_id, session_maker)
|
||||
|
||||
@classmethod
|
||||
async def get_resolver_instance(
|
||||
cls,
|
||||
config: OpenHandsConfig,
|
||||
user_id: str,
|
||||
resolver_org_id: UUID | None = None,
|
||||
) -> 'SaasConversationStore':
|
||||
"""Get a store for resolver conversations with explicit org routing.
|
||||
|
||||
Unlike get_instance, this accepts a resolver_org_id that overrides
|
||||
the user's default org when saving conversation metadata.
|
||||
"""
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
org_id = user.current_org_id if user else None
|
||||
return SaasConversationStore(user_id, org_id, session_maker, resolver_org_id)
|
||||
|
||||
@@ -182,13 +182,7 @@ class SaasSettingsStore(SettingsStore):
|
||||
return None
|
||||
|
||||
# Check if we need to generate an LLM key.
|
||||
# Only generate/verify proxy keys when the base URL is explicitly the
|
||||
# LiteLLM proxy, or when it's unset and the model is an OpenHands model
|
||||
# (which always needs a proxy key). For non-OpenHands models with no
|
||||
# base URL (e.g. basic view BYOR), preserve the user's own API key.
|
||||
if item.llm_base_url == LITE_LLM_API_URL or (
|
||||
not item.llm_base_url and is_openhands_model(item.llm_model)
|
||||
):
|
||||
if not item.llm_base_url or item.llm_base_url == LITE_LLM_API_URL:
|
||||
await self._ensure_api_key(
|
||||
item, str(org_id), openhands_type=is_openhands_model(item.llm_model)
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ SQLAlchemy model for User.
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
UUID,
|
||||
Boolean,
|
||||
Column,
|
||||
@@ -35,7 +34,6 @@ class User(Base): # type: ignore
|
||||
git_user_name = Column(String, nullable=True)
|
||||
git_user_email = Column(String, nullable=True)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
disabled_skills = Column(JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
role = relationship('Role', back_populates='users')
|
||||
|
||||
@@ -13,6 +13,7 @@ Required environment variables:
|
||||
- RESEND_AUDIENCE_ID: ID of the Resend audience to add users to
|
||||
|
||||
Optional environment variables:
|
||||
- KEYCLOAK_PROVIDER_NAME: Provider name for Keycloak
|
||||
- KEYCLOAK_CLIENT_ID: Client ID for Keycloak
|
||||
- KEYCLOAK_CLIENT_SECRET: Client secret for Keycloak
|
||||
- RESEND_FROM_EMAIL: Email address to use as the sender (default: "OpenHands Team <no-reply@welcome.openhands.dev>")
|
||||
@@ -48,6 +49,7 @@ from openhands.core.logger import openhands_logger as logger
|
||||
# Get Keycloak configuration from environment variables
|
||||
KEYCLOAK_SERVER_URL = os.environ.get('KEYCLOAK_SERVER_URL', '')
|
||||
KEYCLOAK_REALM_NAME = os.environ.get('KEYCLOAK_REALM_NAME', '')
|
||||
KEYCLOAK_PROVIDER_NAME = os.environ.get('KEYCLOAK_PROVIDER_NAME', '')
|
||||
KEYCLOAK_CLIENT_ID = os.environ.get('KEYCLOAK_CLIENT_ID', '')
|
||||
KEYCLOAK_CLIENT_SECRET = os.environ.get('KEYCLOAK_CLIENT_SECRET', '')
|
||||
KEYCLOAK_ADMIN_PASSWORD = os.environ.get('KEYCLOAK_ADMIN_PASSWORD', '')
|
||||
|
||||
@@ -25,7 +25,6 @@ from storage.device_code import DeviceCode # noqa: F401
|
||||
from storage.feedback import Feedback
|
||||
from storage.github_app_installation import GithubAppInstallation
|
||||
from storage.org import Org
|
||||
from storage.org_git_claim import OrgGitClaim # noqa: F401
|
||||
from storage.org_invitation import OrgInvitation # noqa: F401
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
|
||||
@@ -88,7 +88,6 @@ class TestGithubViewV1InitialUserMessage:
|
||||
view.previous_comments = [MagicMock(author='alice', body='old comment 1')]
|
||||
|
||||
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
|
||||
view.resolved_org_id = None
|
||||
|
||||
fake_service = _FakeAppConversationService()
|
||||
mock_get_app_conversation_service.return_value = (
|
||||
@@ -145,7 +144,6 @@ class TestGithubViewV1InitialUserMessage:
|
||||
]
|
||||
|
||||
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
|
||||
view.resolved_org_id = None
|
||||
|
||||
fake_service = _FakeAppConversationService()
|
||||
mock_get_app_conversation_service.return_value = (
|
||||
@@ -202,7 +200,6 @@ class TestGithubViewV1InitialUserMessage:
|
||||
view.previous_comments = []
|
||||
|
||||
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
|
||||
view.resolved_org_id = None
|
||||
|
||||
fake_service = _FakeAppConversationService()
|
||||
mock_get_service.return_value = _fake_app_conversation_service_ctx(fake_service)
|
||||
|
||||
@@ -73,7 +73,6 @@ def sample_user_auth():
|
||||
"""Create a mock UserAuth for testing."""
|
||||
user_auth = MagicMock(spec=UserAuth)
|
||||
user_auth.get_provider_tokens = AsyncMock(return_value={})
|
||||
user_auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
|
||||
user_auth.get_access_token = AsyncMock(return_value='test_token')
|
||||
user_auth.get_user_id = AsyncMock(return_value='test_user_id')
|
||||
return user_auth
|
||||
|
||||
@@ -29,33 +29,27 @@ class TestLinearNewConversationView:
|
||||
assert 'Test Issue' in user_msg
|
||||
assert 'Fix this bug @openhands' in user_msg
|
||||
|
||||
@patch(
|
||||
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
|
||||
@patch('integrations.linear.linear_view.create_new_conversation')
|
||||
@patch('integrations.linear.linear_view.integration_store')
|
||||
async def test_create_or_update_conversation_success(
|
||||
self,
|
||||
mock_integration_store,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_store,
|
||||
mock_create_conversation,
|
||||
new_conversation_view,
|
||||
mock_jinja_env,
|
||||
mock_agent_loop_info,
|
||||
):
|
||||
"""Test successful conversation creation"""
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_integration_store.create_conversation = AsyncMock()
|
||||
mock_create_conversation.return_value = mock_agent_loop_info
|
||||
mock_store.create_conversation = AsyncMock()
|
||||
|
||||
result = await new_conversation_view.create_or_update_conversation(
|
||||
mock_jinja_env
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
mock_start_convo.assert_called_once()
|
||||
mock_integration_store.create_conversation.assert_called_once()
|
||||
assert result == 'conv-123'
|
||||
mock_create_conversation.assert_called_once()
|
||||
mock_store.create_conversation.assert_called_once()
|
||||
|
||||
async def test_create_or_update_conversation_no_repo(
|
||||
self, new_conversation_view, mock_jinja_env
|
||||
@@ -66,23 +60,12 @@ class TestLinearNewConversationView:
|
||||
with pytest.raises(StartingConvoException, match='No repository selected'):
|
||||
await new_conversation_view.create_or_update_conversation(mock_jinja_env)
|
||||
|
||||
@patch(
|
||||
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
|
||||
@patch('integrations.linear.linear_view.create_new_conversation')
|
||||
async def test_create_or_update_conversation_failure(
|
||||
self,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
new_conversation_view,
|
||||
mock_jinja_env,
|
||||
self, mock_create_conversation, new_conversation_view, mock_jinja_env
|
||||
):
|
||||
"""Test conversation creation failure"""
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_start_convo.side_effect = Exception('Creation failed')
|
||||
mock_create_conversation.side_effect = Exception('Creation failed')
|
||||
|
||||
with pytest.raises(
|
||||
StartingConvoException, match='Failed to create conversation'
|
||||
@@ -317,57 +300,43 @@ class TestLinearFactory:
|
||||
class TestLinearViewEdgeCases:
|
||||
"""Tests for edge cases and error scenarios"""
|
||||
|
||||
@patch(
|
||||
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
|
||||
@patch('integrations.linear.linear_view.create_new_conversation')
|
||||
@patch('integrations.linear.linear_view.integration_store')
|
||||
async def test_conversation_creation_with_no_user_secrets(
|
||||
self,
|
||||
mock_integration_store,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_store,
|
||||
mock_create_conversation,
|
||||
new_conversation_view,
|
||||
mock_jinja_env,
|
||||
mock_agent_loop_info,
|
||||
):
|
||||
"""Test conversation creation when user has no secrets"""
|
||||
new_conversation_view.saas_user_auth.get_secrets = AsyncMock(return_value=None)
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_integration_store.create_conversation = AsyncMock()
|
||||
new_conversation_view.saas_user_auth.get_secrets.return_value = None
|
||||
mock_create_conversation.return_value = mock_agent_loop_info
|
||||
mock_store.create_conversation = AsyncMock()
|
||||
|
||||
result = await new_conversation_view.create_or_update_conversation(
|
||||
mock_jinja_env
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
# Verify start_conversation was called with custom_secrets=None
|
||||
call_kwargs = mock_start_convo.call_args[1]
|
||||
assert result == 'conv-123'
|
||||
# Verify create_new_conversation was called with custom_secrets=None
|
||||
call_kwargs = mock_create_conversation.call_args[1]
|
||||
assert call_kwargs['custom_secrets'] is None
|
||||
|
||||
@patch(
|
||||
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
|
||||
@patch('integrations.linear.linear_view.create_new_conversation')
|
||||
@patch('integrations.linear.linear_view.integration_store')
|
||||
async def test_conversation_creation_store_failure(
|
||||
self,
|
||||
mock_integration_store,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_store,
|
||||
mock_create_conversation,
|
||||
new_conversation_view,
|
||||
mock_jinja_env,
|
||||
mock_agent_loop_info,
|
||||
):
|
||||
"""Test conversation creation when store creation fails"""
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_integration_store.create_conversation = AsyncMock(
|
||||
side_effect=Exception('Store error')
|
||||
)
|
||||
mock_create_conversation.return_value = mock_agent_loop_info
|
||||
mock_store.create_conversation = AsyncMock(side_effect=Exception('Store error'))
|
||||
|
||||
with pytest.raises(
|
||||
StartingConvoException, match='Failed to create conversation'
|
||||
|
||||
@@ -32,28 +32,6 @@ def resolver_context(mock_saas_user_auth):
|
||||
return ResolverUserContext(saas_user_auth=mock_saas_user_auth)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for resolver_org_id - org routing for resolver conversations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolver_org_id_defaults_to_none(mock_saas_user_auth):
|
||||
"""Test that resolver_org_id defaults to None when not provided."""
|
||||
ctx = ResolverUserContext(saas_user_auth=mock_saas_user_auth)
|
||||
assert ctx.resolver_org_id is None
|
||||
|
||||
|
||||
def test_resolver_org_id_can_be_set_via_constructor(mock_saas_user_auth):
|
||||
"""Test that resolver_org_id can be set via constructor for org routing."""
|
||||
from uuid import UUID
|
||||
|
||||
org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
ctx = ResolverUserContext(
|
||||
saas_user_auth=mock_saas_user_auth, resolver_org_id=org_id
|
||||
)
|
||||
assert ctx.resolver_org_id == org_id
|
||||
|
||||
|
||||
def create_custom_secret(value: str, description: str = 'Test secret') -> CustomSecret:
|
||||
"""Helper to create CustomSecret instances."""
|
||||
return CustomSecret(secret=SecretStr(value), description=description)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Tests for resolver org routing logic.
|
||||
|
||||
Tests the resolve_org_for_repo function which determines which OpenHands
|
||||
organization workspace a resolver conversation should be created in.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
USER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
|
||||
# Patch at module level where the names are looked up
|
||||
_CLAIM_STORE = 'enterprise.integrations.resolver_org_router.OrgGitClaimStore'
|
||||
_MEMBER_STORE = 'enterprise.integrations.resolver_org_router.OrgMemberStore'
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_stores():
|
||||
"""Mock OrgGitClaimStore and OrgMemberStore for all tests."""
|
||||
with (
|
||||
patch(_CLAIM_STORE) as mock_claim_store,
|
||||
patch(_MEMBER_STORE) as mock_member_store,
|
||||
):
|
||||
mock_claim_store.get_claim_by_provider_and_git_org = AsyncMock(
|
||||
return_value=None
|
||||
)
|
||||
mock_member_store.get_org_member = AsyncMock(return_value=None)
|
||||
yield mock_claim_store, mock_member_store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_org_id_when_claimed_and_user_is_member(mock_stores):
|
||||
"""When the git org is claimed and the user is a member, return the claiming org's ID."""
|
||||
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
|
||||
|
||||
mock_claim_store, mock_member_store = mock_stores
|
||||
|
||||
# Arrange
|
||||
claim = MagicMock()
|
||||
claim.org_id = CLAIMING_ORG_ID
|
||||
mock_claim_store.get_claim_by_provider_and_git_org.return_value = claim
|
||||
mock_member_store.get_org_member.return_value = MagicMock() # member exists
|
||||
|
||||
# Act
|
||||
result = await resolve_org_for_repo('github', 'OpenHands/foo', USER_ID)
|
||||
|
||||
# Assert
|
||||
assert result == CLAIMING_ORG_ID
|
||||
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
|
||||
'github', 'openhands'
|
||||
)
|
||||
mock_member_store.get_org_member.assert_called_once_with(
|
||||
CLAIMING_ORG_ID, UUID(USER_ID)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_claimed_but_user_not_member(mock_stores):
|
||||
"""When the git org is claimed but user is not a member, return None."""
|
||||
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
|
||||
|
||||
mock_claim_store, mock_member_store = mock_stores
|
||||
|
||||
# Arrange
|
||||
claim = MagicMock()
|
||||
claim.org_id = CLAIMING_ORG_ID
|
||||
mock_claim_store.get_claim_by_provider_and_git_org.return_value = claim
|
||||
mock_member_store.get_org_member.return_value = None
|
||||
|
||||
# Act
|
||||
result = await resolve_org_for_repo('github', 'OpenHands/foo', USER_ID)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_no_claim_exists(mock_stores):
|
||||
"""When no org has claimed the git organization, return None."""
|
||||
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
|
||||
|
||||
mock_claim_store, _ = mock_stores
|
||||
mock_claim_store.get_claim_by_provider_and_git_org.return_value = None
|
||||
|
||||
# Act
|
||||
result = await resolve_org_for_repo('github', 'UnclaimedOrg/repo', USER_ID)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
|
||||
'github', 'unclaimedorg'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extracts_git_org_lowercase_from_repo_name(mock_stores):
|
||||
"""The git org is extracted from repo name and lowercased for claim lookup."""
|
||||
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
|
||||
|
||||
mock_claim_store, _ = mock_stores
|
||||
|
||||
# Act
|
||||
await resolve_org_for_repo('github', 'MyOrg/some-repo', USER_ID)
|
||||
|
||||
# Assert
|
||||
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
|
||||
'github', 'myorg'
|
||||
)
|
||||
@@ -1,603 +0,0 @@
|
||||
"""Tests for Git organization claim API endpoints.
|
||||
|
||||
Tests the following endpoints:
|
||||
- GET /api/organizations/{org_id}/git-claims (list claims)
|
||||
- POST /api/organizations/{org_id}/git-claims (claim)
|
||||
- DELETE /api/organizations/{org_id}/git-claims/{claim_id} (disconnect)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, status
|
||||
from fastapi.testclient import TestClient
|
||||
from server.routes.orgs import (
|
||||
claim_git_organization,
|
||||
disconnect_git_organization,
|
||||
get_git_claims,
|
||||
org_router,
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from storage.org_git_claim import OrgGitClaim
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
TEST_USER_ID = str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org_id():
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_id():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_claim():
|
||||
"""Factory to create mock OrgGitClaim objects."""
|
||||
|
||||
def _make(org_id, provider='github', git_organization='OpenHands', claimed_by=None):
|
||||
claim = MagicMock(spec=OrgGitClaim)
|
||||
claim.id = uuid.uuid4()
|
||||
claim.org_id = org_id
|
||||
claim.provider = provider
|
||||
claim.git_organization = git_organization
|
||||
claim.claimed_by = claimed_by or uuid.uuid4()
|
||||
claim.claimed_at = datetime(2026, 4, 1, 12, 0, 0)
|
||||
return claim
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /api/organizations/{org_id}/git-claims
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGetGitClaims:
|
||||
"""Tests for the get Git organization claims endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_list_when_no_claims(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: An organization with no Git claims
|
||||
WHEN: GET /api/organizations/{org_id}/git-claims is called
|
||||
THEN: An empty list is returned
|
||||
"""
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claims_by_org_id',
|
||||
AsyncMock(return_value=[]),
|
||||
) as mock_get:
|
||||
result = await get_git_claims(org_id=org_id, user_id=user_id)
|
||||
|
||||
assert result == []
|
||||
mock_get.assert_called_once_with(org_id=org_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_claims_for_organization(self, org_id, user_id, make_claim):
|
||||
"""
|
||||
GIVEN: An organization with multiple Git claims
|
||||
WHEN: GET /api/organizations/{org_id}/git-claims is called
|
||||
THEN: All claims are returned with correct details
|
||||
"""
|
||||
claim1 = make_claim(org_id, provider='github', git_organization='OpenHands')
|
||||
claim2 = make_claim(org_id, provider='gitlab', git_organization='AcmeCo')
|
||||
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claims_by_org_id',
|
||||
AsyncMock(return_value=[claim1, claim2]),
|
||||
):
|
||||
result = await get_git_claims(org_id=org_id, user_id=user_id)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].id == str(claim1.id)
|
||||
assert result[0].org_id == str(org_id)
|
||||
assert result[0].provider == 'github'
|
||||
assert result[0].git_organization == 'OpenHands'
|
||||
assert result[0].claimed_by == str(claim1.claimed_by)
|
||||
assert result[0].claimed_at == '2026-04-01T12:00:00'
|
||||
assert result[1].id == str(claim2.id)
|
||||
assert result[1].provider == 'gitlab'
|
||||
assert result[1].git_organization == 'AcmeCo'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_500_on_unexpected_error(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: An unexpected error occurs when fetching claims
|
||||
WHEN: GET /api/organizations/{org_id}/git-claims is called
|
||||
THEN: A 500 Internal Server Error is returned
|
||||
"""
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claims_by_org_id',
|
||||
AsyncMock(side_effect=RuntimeError('db connection failed')),
|
||||
):
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await get_git_claims(org_id=org_id, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /api/organizations/{org_id}/git-claims
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestClaimGitOrganization:
|
||||
"""Tests for the claim Git organization endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_succeeds_for_unclaimed_org(self, org_id, user_id, make_claim):
|
||||
"""
|
||||
GIVEN: A Git organization that has not been claimed
|
||||
WHEN: POST /api/organizations/{org_id}/git-claims is called
|
||||
THEN: The claim is created and returned with correct details
|
||||
"""
|
||||
# Arrange
|
||||
mock_claim = make_claim(org_id, claimed_by=uuid.UUID(user_id))
|
||||
request = MagicMock()
|
||||
request.provider = 'github'
|
||||
request.git_organization = 'OpenHands'
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.create_claim',
|
||||
AsyncMock(return_value=mock_claim),
|
||||
) as mock_create,
|
||||
):
|
||||
# Act
|
||||
response = await claim_git_organization(
|
||||
org_id=org_id, request=request, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.id == str(mock_claim.id)
|
||||
assert response.org_id == str(org_id)
|
||||
assert response.provider == 'github'
|
||||
assert response.git_organization == 'OpenHands'
|
||||
assert response.claimed_by == user_id
|
||||
mock_create.assert_called_once_with(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='OpenHands',
|
||||
claimed_by=uuid.UUID(user_id),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_fails_when_already_claimed(self, org_id, user_id, make_claim):
|
||||
"""
|
||||
GIVEN: A Git organization already claimed by another OpenHands org
|
||||
WHEN: POST /api/organizations/{org_id}/git-claims is called
|
||||
THEN: A 409 Conflict error is returned
|
||||
"""
|
||||
# Arrange
|
||||
other_org_id = uuid.uuid4()
|
||||
existing_claim = make_claim(
|
||||
other_org_id, provider='github', git_organization='AlreadyClaimed'
|
||||
)
|
||||
request = MagicMock()
|
||||
request.provider = 'github'
|
||||
request.git_organization = 'AlreadyClaimed'
|
||||
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
|
||||
AsyncMock(return_value=existing_claim),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await claim_git_organization(
|
||||
org_id=org_id, request=request, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_returns_500_on_unexpected_error(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: An unexpected error occurs during claim creation
|
||||
WHEN: POST /api/organizations/{org_id}/git-claims is called
|
||||
THEN: A 500 Internal Server Error is returned
|
||||
"""
|
||||
# Arrange
|
||||
request = MagicMock()
|
||||
request.provider = 'github'
|
||||
request.git_organization = 'OpenHands'
|
||||
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
|
||||
AsyncMock(side_effect=RuntimeError('db connection failed')),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await claim_git_organization(
|
||||
org_id=org_id, request=request, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_race_condition_returns_409(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: Pre-check passes but a concurrent request claims the org first
|
||||
WHEN: create_claim raises IntegrityError (DB unique constraint)
|
||||
THEN: A 409 Conflict error is returned instead of 500
|
||||
"""
|
||||
# Arrange
|
||||
request = MagicMock()
|
||||
request.provider = 'github'
|
||||
request.git_organization = 'RaceOrg'
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.create_claim',
|
||||
AsyncMock(
|
||||
side_effect=IntegrityError(
|
||||
'duplicate',
|
||||
'',
|
||||
Exception('uq_provider_git_org'),
|
||||
)
|
||||
),
|
||||
),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await claim_git_organization(
|
||||
org_id=org_id, request=request, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DELETE /api/organizations/{org_id}/git-claims/{claim_id}
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDisconnectGitOrganization:
|
||||
"""Tests for the disconnect Git organization endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_succeeds_for_existing_claim(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: A valid claim belonging to the organization
|
||||
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} is called
|
||||
THEN: The claim is deleted and a success message is returned
|
||||
"""
|
||||
# Arrange
|
||||
claim_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.delete_claim',
|
||||
AsyncMock(return_value=True),
|
||||
) as mock_delete:
|
||||
# Act
|
||||
result = await disconnect_git_organization(
|
||||
org_id=org_id, claim_id=claim_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == {'message': 'Git organization claim removed successfully'}
|
||||
mock_delete.assert_called_once_with(claim_id=claim_id, org_id=org_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_fails_when_claim_not_found(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: A claim_id that does not exist for this organization
|
||||
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} is called
|
||||
THEN: A 404 Not Found error is returned
|
||||
"""
|
||||
# Arrange
|
||||
claim_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.delete_claim',
|
||||
AsyncMock(return_value=False),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await disconnect_git_organization(
|
||||
org_id=org_id, claim_id=claim_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_returns_500_on_unexpected_error(self, org_id, user_id):
|
||||
"""
|
||||
GIVEN: An unexpected error occurs during claim deletion
|
||||
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} is called
|
||||
THEN: A 500 Internal Server Error is returned
|
||||
"""
|
||||
# Arrange
|
||||
claim_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.delete_claim',
|
||||
AsyncMock(side_effect=RuntimeError('db connection failed')),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await disconnect_git_organization(
|
||||
org_id=org_id, claim_id=claim_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Validation tests for GitOrgClaimRequest
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGitOrgClaimRequestValidation:
|
||||
"""Tests for request model validation."""
|
||||
|
||||
def test_valid_providers_are_accepted(self):
|
||||
"""Each supported provider is accepted and normalized to lowercase."""
|
||||
from server.routes.org_models import GitOrgClaimRequest
|
||||
|
||||
for provider in ['github', 'GitLab', 'BITBUCKET']:
|
||||
req = GitOrgClaimRequest(provider=provider, git_organization='test-org')
|
||||
assert req.provider == provider.lower().strip()
|
||||
|
||||
def test_invalid_provider_is_rejected(self):
|
||||
"""An unsupported provider raises a validation error."""
|
||||
from pydantic import ValidationError
|
||||
from server.routes.org_models import GitOrgClaimRequest
|
||||
|
||||
with pytest.raises(ValidationError, match='Invalid provider'):
|
||||
GitOrgClaimRequest(provider='azure_devops', git_organization='test-org')
|
||||
|
||||
def test_empty_git_organization_is_rejected(self):
|
||||
"""An empty git_organization raises a validation error."""
|
||||
from pydantic import ValidationError
|
||||
from server.routes.org_models import GitOrgClaimRequest
|
||||
|
||||
with pytest.raises(ValidationError, match='git_organization must not be empty'):
|
||||
GitOrgClaimRequest(provider='github', git_organization=' ')
|
||||
|
||||
def test_git_organization_is_normalized_to_lowercase(self):
|
||||
"""git_organization is lowercased to prevent case-sensitive duplicates."""
|
||||
from server.routes.org_models import GitOrgClaimRequest
|
||||
|
||||
req = GitOrgClaimRequest(provider='github', git_organization='OpenHands')
|
||||
assert req.git_organization == 'openhands'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Integration tests — TestClient with real HTTP, auth, and Pydantic validation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app():
|
||||
"""FastAPI app with org routes and mocked user authentication."""
|
||||
app = FastAPI()
|
||||
app.include_router(org_router)
|
||||
|
||||
app.dependency_overrides[get_user_id] = lambda: TEST_USER_ID
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_owner_role():
|
||||
role = MagicMock()
|
||||
role.name = 'owner'
|
||||
return role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_member_role():
|
||||
role = MagicMock()
|
||||
role.name = 'member'
|
||||
return role
|
||||
|
||||
|
||||
class TestGitClaimsAuthorization:
|
||||
"""Integration tests verifying authorization through the real HTTP cycle."""
|
||||
|
||||
def test_non_member_gets_403_on_get(self, mock_app):
|
||||
"""
|
||||
GIVEN: A user who is not a member of the target organization
|
||||
WHEN: GET /api/organizations/{org_id}/git-claims via HTTP
|
||||
THEN: 403 is returned by require_permission
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.get(f'/api/organizations/{org_id}/git-claims')
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'not a member' in response.json()['detail']
|
||||
|
||||
def test_member_without_permission_gets_403_on_post(
|
||||
self, mock_app, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: A user with member role (lacks MANAGE_ORG_CLAIMS)
|
||||
WHEN: POST /api/organizations/{org_id}/git-claims via HTTP
|
||||
THEN: 403 is returned by require_permission
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.post(
|
||||
f'/api/organizations/{org_id}/git-claims',
|
||||
json={'provider': 'github', 'git_organization': 'SomeOrg'},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'manage_org_claims' in response.json()['detail']
|
||||
|
||||
def test_member_without_permission_gets_403_on_delete(
|
||||
self, mock_app, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: A user with member role (lacks MANAGE_ORG_CLAIMS)
|
||||
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} via HTTP
|
||||
THEN: 403 is returned by require_permission
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
claim_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.delete(
|
||||
f'/api/organizations/{org_id}/git-claims/{claim_id}'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'manage_org_claims' in response.json()['detail']
|
||||
|
||||
|
||||
class TestGitClaimsHTTPIntegration:
|
||||
"""Integration tests for the full request/response cycle via TestClient."""
|
||||
|
||||
def test_post_claim_with_invalid_provider_returns_422(
|
||||
self, mock_app, mock_owner_role
|
||||
):
|
||||
"""
|
||||
GIVEN: A request with an unsupported provider
|
||||
WHEN: POST /api/organizations/{org_id}/git-claims via HTTP
|
||||
THEN: 422 is returned by Pydantic validation
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_owner_role),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.post(
|
||||
f'/api/organizations/{org_id}/git-claims',
|
||||
json={'provider': 'azure_devops', 'git_organization': 'test'},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
def test_post_claim_success_returns_201(self, mock_app, mock_owner_role):
|
||||
"""
|
||||
GIVEN: A valid claim request by an authorized admin/owner
|
||||
WHEN: POST /api/organizations/{org_id}/git-claims via HTTP
|
||||
THEN: 201 is returned with the claim details
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
mock_claim = MagicMock(spec=OrgGitClaim)
|
||||
mock_claim.id = uuid.uuid4()
|
||||
mock_claim.org_id = org_id
|
||||
mock_claim.provider = 'github'
|
||||
mock_claim.git_organization = 'openhands'
|
||||
mock_claim.claimed_by = uuid.UUID(TEST_USER_ID)
|
||||
mock_claim.claimed_at = datetime(2026, 4, 1, 12, 0, 0)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_owner_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.create_claim',
|
||||
AsyncMock(return_value=mock_claim),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.post(
|
||||
f'/api/organizations/{org_id}/git-claims',
|
||||
json={'provider': 'github', 'git_organization': 'OpenHands'},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data['org_id'] == str(org_id)
|
||||
assert data['provider'] == 'github'
|
||||
assert data['git_organization'] == 'openhands'
|
||||
|
||||
def test_delete_claim_success_returns_200(self, mock_app, mock_owner_role):
|
||||
"""
|
||||
GIVEN: A valid disconnect request by an authorized admin/owner
|
||||
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} via HTTP
|
||||
THEN: 200 is returned with a success message
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
claim_id = uuid.uuid4()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_owner_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.delete_claim',
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.delete(
|
||||
f'/api/organizations/{org_id}/git-claims/{claim_id}'
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert (
|
||||
response.json()['message'] == 'Git organization claim removed successfully'
|
||||
)
|
||||
|
||||
def test_get_claims_success_returns_200(self, mock_app, mock_owner_role):
|
||||
"""
|
||||
GIVEN: An authorized user requests claims for their organization
|
||||
WHEN: GET /api/organizations/{org_id}/git-claims via HTTP
|
||||
THEN: 200 is returned with the list of claims
|
||||
"""
|
||||
org_id = uuid.uuid4()
|
||||
mock_claim = MagicMock(spec=OrgGitClaim)
|
||||
mock_claim.id = uuid.uuid4()
|
||||
mock_claim.org_id = org_id
|
||||
mock_claim.provider = 'github'
|
||||
mock_claim.git_organization = 'openhands'
|
||||
mock_claim.claimed_by = uuid.UUID(TEST_USER_ID)
|
||||
mock_claim.claimed_at = datetime(2026, 4, 1, 12, 0, 0)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_owner_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgGitClaimStore.get_claims_by_org_id',
|
||||
AsyncMock(return_value=[mock_claim]),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app)
|
||||
response = client.get(f'/api/organizations/{org_id}/git-claims')
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]['provider'] == 'github'
|
||||
assert data[0]['git_organization'] == 'openhands'
|
||||
@@ -1,420 +0,0 @@
|
||||
"""Tests for OrgMemberFinancialService."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from server.routes.org_models import OrgMemberFinancialPage
|
||||
from server.services.org_member_financial_service import OrgMemberFinancialService
|
||||
from storage.org_member import OrgMember
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org_id():
|
||||
"""Create a test organization ID."""
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user():
|
||||
"""Create a mock user."""
|
||||
user = MagicMock()
|
||||
user.email = 'test@example.com'
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_role():
|
||||
"""Create a mock role."""
|
||||
role = MagicMock()
|
||||
role.id = 1
|
||||
role.name = 'member'
|
||||
role.rank = 1000
|
||||
return role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_org_member(org_id, mock_user, mock_role):
|
||||
"""Create a mock org member with user and role."""
|
||||
member = MagicMock(spec=OrgMember)
|
||||
member.org_id = org_id
|
||||
member.user_id = uuid.uuid4()
|
||||
member.role_id = mock_role.id
|
||||
member.status = 'active'
|
||||
member.user = mock_user
|
||||
member.role = mock_role
|
||||
return member
|
||||
|
||||
|
||||
class TestOrgMemberFinancialServiceGetFinancialData:
|
||||
"""Test cases for OrgMemberFinancialService.get_org_members_financial_data."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_paginated_financial_data_with_individual_budget(
|
||||
self, org_id, mock_org_member
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization with members having individual budget limits
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns financial data using individual spend for current_budget calc
|
||||
"""
|
||||
# Arrange
|
||||
user_id_str = str(mock_org_member.user_id)
|
||||
litellm_data = {
|
||||
'team_max_budget': 1000.0,
|
||||
'team_spend': 200.0,
|
||||
'members': {
|
||||
user_id_str: {'spend': 125.50, 'max_budget': 500.0} # Individual budget
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 1)
|
||||
mock_get_financial.return_value = litellm_data
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
page_id=None,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgMemberFinancialPage)
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].user_id == user_id_str
|
||||
assert result.items[0].email == 'test@example.com'
|
||||
assert result.items[0].lifetime_spend == 125.50
|
||||
assert result.items[0].max_budget == 500.0
|
||||
# Individual budget: 500 - 125.50 = 374.50
|
||||
assert result.items[0].current_budget == 374.50
|
||||
assert result.current_page == 1
|
||||
assert result.per_page == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_shared_budget_using_team_spend(
|
||||
self, org_id, mock_org_member
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization with shared team budget
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Uses team_spend (not individual spend) for current_budget calculation
|
||||
"""
|
||||
# Arrange
|
||||
user_id_str = str(mock_org_member.user_id)
|
||||
litellm_data = {
|
||||
'team_max_budget': 500.0,
|
||||
'team_spend': 150.0, # Total team spend
|
||||
'members': {
|
||||
user_id_str: {
|
||||
'spend': 50.0,
|
||||
'max_budget': 500.0,
|
||||
'uses_shared_budget': True, # Explicitly using shared budget
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 1)
|
||||
mock_get_financial.return_value = litellm_data
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].lifetime_spend == 50.0 # Individual spend
|
||||
assert result.items[0].max_budget == 500.0
|
||||
# Shared budget: 500 - 150 (team_spend) = 350
|
||||
assert result.items[0].current_budget == 350.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_defaults_when_litellm_data_missing(
|
||||
self, org_id, mock_org_member
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization with members but no LiteLLM data for them
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns financial data with default values (spend=0, budget=None)
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 1)
|
||||
mock_get_financial.return_value = {
|
||||
'team_max_budget': None,
|
||||
'team_spend': 0,
|
||||
'members': {},
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].lifetime_spend == 0
|
||||
assert result.items[0].max_budget is None
|
||||
assert result.items[0].current_budget == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_litellm_failure_gracefully(self, org_id, mock_org_member):
|
||||
"""
|
||||
GIVEN: LiteLLM service throws an exception
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns financial data with default values (doesn't fail)
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 1)
|
||||
mock_get_financial.side_effect = Exception('LiteLLM unavailable')
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
# Assert - should not raise, returns defaults
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].lifetime_spend == 0
|
||||
assert result.items[0].max_budget is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagination_returns_next_page_id(self, org_id, mock_org_member):
|
||||
"""
|
||||
GIVEN: Organization with more members than limit
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns next_page_id for pagination
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 25) # 25 total
|
||||
mock_get_financial.return_value = {
|
||||
'team_max_budget': None,
|
||||
'team_spend': 0,
|
||||
'members': {},
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
page_id='0',
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.current_page == 1
|
||||
assert result.next_page_id == '10'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagination_no_next_page_on_last_page(self, org_id, mock_org_member):
|
||||
"""
|
||||
GIVEN: Organization on last page of results
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns next_page_id as None
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 5) # 5 total
|
||||
mock_get_financial.return_value = {
|
||||
'team_max_budget': None,
|
||||
'team_spend': 0,
|
||||
'members': {},
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
page_id='0',
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.next_page_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_organization_returns_empty_items(self, org_id):
|
||||
"""
|
||||
GIVEN: Organization with no members
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns empty items list
|
||||
"""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated:
|
||||
mock_get_paginated.return_value = ([], 0)
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result.items) == 0
|
||||
assert result.next_page_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_page_id_raises_value_error(self, org_id):
|
||||
"""
|
||||
GIVEN: Invalid page_id format
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Raises ValueError
|
||||
"""
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
page_id='invalid',
|
||||
)
|
||||
|
||||
assert 'Invalid page_id' in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_page_id_raises_value_error(self, org_id):
|
||||
"""
|
||||
GIVEN: Negative page_id
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Raises ValueError
|
||||
"""
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
page_id='-5',
|
||||
)
|
||||
|
||||
assert 'Invalid page_id' in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_email_filter_to_store(self, org_id, mock_org_member):
|
||||
"""
|
||||
GIVEN: Email filter parameter
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Passes email filter to the store
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([mock_org_member], 1)
|
||||
mock_get_financial.return_value = {
|
||||
'team_max_budget': None,
|
||||
'team_spend': 0,
|
||||
'members': {},
|
||||
}
|
||||
|
||||
# Act
|
||||
await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
email_filter='alice',
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_get_paginated.assert_called_once_with(
|
||||
org_id=org_id, offset=0, limit=10, email_filter='alice'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_missing_user_relationship(self, org_id, mock_role):
|
||||
"""
|
||||
GIVEN: Member with no user relationship loaded
|
||||
WHEN: get_org_members_financial_data is called
|
||||
THEN: Returns None for email
|
||||
"""
|
||||
# Arrange
|
||||
member_no_user = MagicMock(spec=OrgMember)
|
||||
member_no_user.org_id = org_id
|
||||
member_no_user.user_id = uuid.uuid4()
|
||||
member_no_user.role_id = mock_role.id
|
||||
member_no_user.user = None # No user relationship
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_paginated,
|
||||
patch(
|
||||
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_financial,
|
||||
):
|
||||
mock_get_paginated.return_value = ([member_no_user], 1)
|
||||
mock_get_financial.return_value = {
|
||||
'team_max_budget': None,
|
||||
'team_spend': 0,
|
||||
'members': {},
|
||||
}
|
||||
|
||||
# Act
|
||||
result = await OrgMemberFinancialService.get_org_members_financial_data(
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].email is None
|
||||
@@ -0,0 +1,239 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from server.routes.org_models import OrgAuthorizationError
|
||||
from server.services.org_usage_service import NO_REPOSITORY_LABEL, OrgUsageService
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
from storage.user import User
|
||||
|
||||
from openhands.app_server.user.specifiy_user_context import SpecifyUserContext
|
||||
|
||||
PERSONAL_USER_ID = UUID("aaaaaaaa-1111-1111-1111-111111111111")
|
||||
TEAM_ADMIN_USER_ID = UUID("bbbbbbbb-2222-2222-2222-222222222222")
|
||||
TEAM_MEMBER_USER_ID = UUID("cccccccc-3333-3333-3333-333333333333")
|
||||
TEAM_ORG_ID = UUID("dddddddd-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
session_maker = async_sessionmaker(
|
||||
async_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with session_maker() as session:
|
||||
owner_role = Role(id=1, name="owner", rank=3)
|
||||
admin_role = Role(id=2, name="admin", rank=2)
|
||||
member_role = Role(id=3, name="member", rank=1)
|
||||
session.add_all([owner_role, admin_role, member_role])
|
||||
|
||||
personal_org = Org(
|
||||
id=PERSONAL_USER_ID,
|
||||
name="personal-org",
|
||||
enable_default_condenser=True,
|
||||
enable_proactive_conversation_starters=True,
|
||||
)
|
||||
team_org = Org(
|
||||
id=TEAM_ORG_ID,
|
||||
name="team-org",
|
||||
enable_default_condenser=True,
|
||||
enable_proactive_conversation_starters=True,
|
||||
)
|
||||
session.add_all([personal_org, team_org])
|
||||
|
||||
personal_user = User(id=PERSONAL_USER_ID, current_org_id=PERSONAL_USER_ID)
|
||||
team_admin = User(id=TEAM_ADMIN_USER_ID, current_org_id=TEAM_ORG_ID)
|
||||
team_member = User(id=TEAM_MEMBER_USER_ID, current_org_id=TEAM_ORG_ID)
|
||||
session.add_all([personal_user, team_admin, team_member])
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
OrgMember(
|
||||
org_id=PERSONAL_USER_ID,
|
||||
user_id=PERSONAL_USER_ID,
|
||||
role_id=3,
|
||||
llm_api_key="personal-key",
|
||||
status="active",
|
||||
),
|
||||
OrgMember(
|
||||
org_id=TEAM_ORG_ID,
|
||||
user_id=TEAM_ADMIN_USER_ID,
|
||||
role_id=2,
|
||||
llm_api_key="admin-key",
|
||||
status="active",
|
||||
),
|
||||
OrgMember(
|
||||
org_id=TEAM_ORG_ID,
|
||||
user_id=TEAM_MEMBER_USER_ID,
|
||||
role_id=3,
|
||||
llm_api_key="member-key",
|
||||
status="active",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
now = datetime.now(UTC).replace(microsecond=0)
|
||||
rows = [
|
||||
(
|
||||
"team-1",
|
||||
TEAM_ADMIN_USER_ID,
|
||||
TEAM_ORG_ID,
|
||||
now,
|
||||
2.0,
|
||||
"openhands/backend",
|
||||
"V1",
|
||||
),
|
||||
(
|
||||
"team-2",
|
||||
TEAM_ADMIN_USER_ID,
|
||||
TEAM_ORG_ID,
|
||||
now - timedelta(days=1),
|
||||
4.0,
|
||||
"openhands/backend",
|
||||
"V1",
|
||||
),
|
||||
(
|
||||
"team-3",
|
||||
TEAM_MEMBER_USER_ID,
|
||||
TEAM_ORG_ID,
|
||||
now - timedelta(days=1),
|
||||
6.0,
|
||||
None,
|
||||
"V1",
|
||||
),
|
||||
(
|
||||
"team-4",
|
||||
TEAM_MEMBER_USER_ID,
|
||||
TEAM_ORG_ID,
|
||||
now - timedelta(days=31),
|
||||
10.0,
|
||||
"openhands/frontend",
|
||||
"V1",
|
||||
),
|
||||
(
|
||||
"team-v0",
|
||||
TEAM_ADMIN_USER_ID,
|
||||
TEAM_ORG_ID,
|
||||
now,
|
||||
8.0,
|
||||
"openhands/ignored",
|
||||
"V0",
|
||||
),
|
||||
(
|
||||
"personal-1",
|
||||
PERSONAL_USER_ID,
|
||||
PERSONAL_USER_ID,
|
||||
now,
|
||||
3.5,
|
||||
"openhands/docs",
|
||||
"V1",
|
||||
),
|
||||
]
|
||||
|
||||
for conversation_id, user_id, org_id, created_at, cost, repository, version in rows:
|
||||
session.add(
|
||||
StoredConversationMetadata(
|
||||
conversation_id=conversation_id,
|
||||
created_at=created_at,
|
||||
last_updated_at=created_at,
|
||||
accumulated_cost=cost,
|
||||
selected_repository=repository,
|
||||
conversation_version=version,
|
||||
title=conversation_id,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
StoredConversationMetadataSaas(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_usage_service_returns_team_metrics(db_session: AsyncSession):
|
||||
service = OrgUsageService(
|
||||
db_session=db_session,
|
||||
user_context=SpecifyUserContext(user_id=str(TEAM_ADMIN_USER_ID)),
|
||||
)
|
||||
|
||||
result = await service.get_org_usage(TEAM_ORG_ID)
|
||||
|
||||
assert result.summary.total_conversations == 4
|
||||
assert result.summary.average_cost_per_conversation_last_30_days == pytest.approx(4.0)
|
||||
assert len(result.daily_conversations) == 30
|
||||
|
||||
counts_by_day = {
|
||||
item.date: item.conversation_count for item in result.daily_conversations
|
||||
}
|
||||
today = datetime.now(UTC).date().isoformat()
|
||||
yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat()
|
||||
assert counts_by_day[today] == 1
|
||||
assert counts_by_day[yesterday] == 2
|
||||
|
||||
assert result.top_repositories[0].repository == "openhands/backend"
|
||||
assert result.top_repositories[0].conversation_count == 2
|
||||
assert any(
|
||||
item.repository == NO_REPOSITORY_LABEL and item.conversation_count == 1
|
||||
for item in result.top_repositories
|
||||
)
|
||||
assert any(
|
||||
item.repository == "openhands/frontend" and item.conversation_count == 1
|
||||
for item in result.top_repositories
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_usage_service_allows_personal_workspace_members(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
service = OrgUsageService(
|
||||
db_session=db_session,
|
||||
user_context=SpecifyUserContext(user_id=str(PERSONAL_USER_ID)),
|
||||
)
|
||||
|
||||
result = await service.get_org_usage(PERSONAL_USER_ID)
|
||||
|
||||
assert result.summary.total_conversations == 1
|
||||
assert result.summary.average_cost_per_conversation_last_30_days == pytest.approx(3.5)
|
||||
assert result.top_repositories[0].repository == "openhands/docs"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_usage_service_rejects_team_members(db_session: AsyncSession):
|
||||
service = OrgUsageService(
|
||||
db_session=db_session,
|
||||
user_context=SpecifyUserContext(user_id=str(TEAM_MEMBER_USER_ID)),
|
||||
)
|
||||
|
||||
with pytest.raises(OrgAuthorizationError):
|
||||
await service.get_org_usage(TEAM_ORG_ID)
|
||||
@@ -1,210 +0,0 @@
|
||||
"""Tests for OrgGitClaimStore with real in-memory SQLite database.
|
||||
|
||||
Covers CRUD operations and unique constraint enforcement.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from storage.org import Org
|
||||
from storage.org_git_claim_store import OrgGitClaimStore
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seed_org_and_user(async_session_maker):
|
||||
"""Create a minimal org, role, user, and org_member for FK satisfaction."""
|
||||
org_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
role_id = 1
|
||||
|
||||
async with async_session_maker() as session:
|
||||
session.add(Role(id=role_id, name='owner', rank=10))
|
||||
session.add(Org(id=org_id, name='test-org'))
|
||||
session.add(User(id=user_id, current_org_id=org_id, role_id=role_id))
|
||||
session.add(
|
||||
OrgMember(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
status='active',
|
||||
llm_api_key='test-key',
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return org_id, user_id
|
||||
|
||||
|
||||
class TestOrgGitClaimStoreCreate:
|
||||
"""Tests for OrgGitClaimStore.create_claim."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_claim_persists_and_returns(
|
||||
self, async_session_maker, seed_org_and_user
|
||||
):
|
||||
"""A new claim is persisted with correct fields and returned."""
|
||||
org_id, user_id = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
claim = await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='OpenHands',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
assert claim.org_id == org_id
|
||||
assert claim.provider == 'github'
|
||||
assert claim.git_organization == 'OpenHands'
|
||||
assert claim.claimed_by == user_id
|
||||
assert claim.claimed_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_raises_integrity_error(
|
||||
self, async_session_maker, seed_org_and_user
|
||||
):
|
||||
"""Creating a duplicate (provider, git_organization) violates the unique constraint."""
|
||||
org_id, user_id = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='DuplicateOrg',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='DuplicateOrg',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
|
||||
class TestOrgGitClaimStoreLookup:
|
||||
"""Tests for OrgGitClaimStore lookup methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_claim_by_provider_and_git_org_found(
|
||||
self, async_session_maker, seed_org_and_user
|
||||
):
|
||||
"""Returns the claim when provider+git_organization exists."""
|
||||
org_id, user_id = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='gitlab',
|
||||
git_organization='MyGroup',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
result = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
|
||||
provider='gitlab', git_organization='MyGroup'
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.provider == 'gitlab'
|
||||
assert result.git_organization == 'MyGroup'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_claim_by_provider_and_git_org_not_found(
|
||||
self, async_session_maker
|
||||
):
|
||||
"""Returns None when no matching claim exists."""
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
result = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
|
||||
provider='github', git_organization='NonExistent'
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_claims_by_org_id(self, async_session_maker, seed_org_and_user):
|
||||
"""Returns all claims belonging to the given org."""
|
||||
org_id, user_id = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='Org1',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='gitlab',
|
||||
git_organization='Org2',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
claims = await OrgGitClaimStore.get_claims_by_org_id(org_id)
|
||||
|
||||
assert len(claims) == 2
|
||||
|
||||
|
||||
class TestOrgGitClaimStoreDelete:
|
||||
"""Tests for OrgGitClaimStore.delete_claim."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_existing_claim_returns_true(
|
||||
self, async_session_maker, seed_org_and_user
|
||||
):
|
||||
"""Deleting an existing claim returns True and removes it from the DB."""
|
||||
org_id, user_id = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
claim = await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='ToDelete',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
result = await OrgGitClaimStore.delete_claim(
|
||||
claim_id=claim.id, org_id=org_id
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_claim_returns_false(
|
||||
self, async_session_maker, seed_org_and_user
|
||||
):
|
||||
"""Deleting a claim that doesn't exist returns False."""
|
||||
org_id, _ = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
result = await OrgGitClaimStore.delete_claim(
|
||||
claim_id=uuid.uuid4(), org_id=org_id
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_claim_wrong_org_returns_false(
|
||||
self, async_session_maker, seed_org_and_user
|
||||
):
|
||||
"""Deleting a claim with a mismatched org_id returns False."""
|
||||
org_id, user_id = seed_org_and_user
|
||||
|
||||
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
|
||||
claim = await OrgGitClaimStore.create_claim(
|
||||
org_id=org_id,
|
||||
provider='github',
|
||||
git_organization='WrongOrg',
|
||||
claimed_by=user_id,
|
||||
)
|
||||
|
||||
result = await OrgGitClaimStore.delete_claim(
|
||||
claim_id=claim.id, org_id=uuid.uuid4()
|
||||
)
|
||||
|
||||
assert result is False
|
||||
@@ -280,8 +280,6 @@ class TestSaasSQLAppConversationInfoService:
|
||||
stored_metadata.reasoning_tokens = 0
|
||||
stored_metadata.context_window = 0
|
||||
stored_metadata.per_turn_token = 0
|
||||
stored_metadata.public = None
|
||||
stored_metadata.tags = {}
|
||||
|
||||
saas_metadata = MagicMock(spec=StoredConversationMetadataSaas)
|
||||
saas_metadata.user_id = UUID('a1111111-1111-1111-1111-111111111111')
|
||||
@@ -1306,100 +1304,3 @@ class TestApiKeyOrgIdHandling:
|
||||
conv_from_org1 = await user_service_org1.get_app_conversation_info(conv_id)
|
||||
assert conv_from_org1 is not None
|
||||
assert conv_from_org1.id == conv_id
|
||||
|
||||
|
||||
class TestResolverOrgIdRouting:
|
||||
"""Test that resolver_org_id on user_context overrides the default org_id."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_uses_resolver_org_id_when_set_on_context(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""When user_context has resolver_org_id, conversation is saved in that org."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
from enterprise.integrations.resolver_context import ResolverUserContext
|
||||
|
||||
# Arrange: user1 is in ORG1, but resolver routes to ORG2
|
||||
# Use spec to prevent MagicMock from auto-creating undefined attributes
|
||||
mock_context = MagicMock(spec=ResolverUserContext)
|
||||
mock_context.get_user_id = AsyncMock(return_value=str(USER1_ID))
|
||||
mock_context.resolver_org_id = ORG2_ID
|
||||
|
||||
service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=mock_context,
|
||||
)
|
||||
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_resolver',
|
||||
title='Resolver Routed Conversation',
|
||||
)
|
||||
|
||||
# Act
|
||||
await service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Assert: conversation is stored in ORG2, not user's default ORG1
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(conv_id)
|
||||
)
|
||||
result = await async_session_with_users.execute(saas_query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
assert saas_metadata is not None
|
||||
assert saas_metadata.org_id == ORG2_ID
|
||||
assert saas_metadata.user_id == USER1_ID
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_uses_default_org_when_resolver_org_id_is_none(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""When resolver_org_id is None, conversation uses user's default org."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
from enterprise.integrations.resolver_context import ResolverUserContext
|
||||
|
||||
# Arrange: user1 in ORG1 with no resolver override
|
||||
# Use spec to prevent MagicMock from auto-creating undefined attributes
|
||||
mock_context = MagicMock(spec=ResolverUserContext)
|
||||
mock_context.get_user_id = AsyncMock(return_value=str(USER1_ID))
|
||||
mock_context.resolver_org_id = None
|
||||
|
||||
service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=mock_context,
|
||||
)
|
||||
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_default',
|
||||
title='Default Org Conversation',
|
||||
)
|
||||
|
||||
# Act
|
||||
await service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Assert: conversation stored in user's default ORG1
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(conv_id)
|
||||
)
|
||||
result = await async_session_with_users.execute(saas_query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
assert saas_metadata is not None
|
||||
assert saas_metadata.org_id == ORG1_ID
|
||||
|
||||
@@ -1008,234 +1008,3 @@ class TestGetApiKeyOrgIdFromRequest:
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for require_financial_data_access dependency
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _create_mock_request_with_email(api_key_org_id=None, user_email='user@example.com'):
|
||||
"""Helper to create a mock request with optional api_key_org_id and email."""
|
||||
mock_request = MagicMock()
|
||||
mock_user_auth = MagicMock()
|
||||
# get_api_key_org_id is sync, not async
|
||||
mock_user_auth.get_api_key_org_id.return_value = api_key_org_id
|
||||
# get_user_email is async
|
||||
mock_user_auth.get_user_email = AsyncMock(return_value=user_email)
|
||||
mock_request.state.user_auth = mock_user_auth
|
||||
return mock_request
|
||||
|
||||
|
||||
class TestRequireFinancialDataAccess:
|
||||
"""Tests for require_financial_data_access compound authorization dependency."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grants_access_for_openhands_email(self):
|
||||
"""
|
||||
GIVEN: User with @openhands.dev email
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Returns user_id (access granted)
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request_with_email(user_email='admin@openhands.dev')
|
||||
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_auth',
|
||||
AsyncMock(return_value=mock_request.state.user_auth),
|
||||
):
|
||||
# Act
|
||||
result = await require_financial_data_access(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grants_access_for_owner_role(self):
|
||||
"""
|
||||
GIVEN: User with owner role in organization (non-@openhands.dev email)
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Returns user_id (access granted)
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request_with_email(user_email='user@company.com')
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'owner'
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_auth',
|
||||
AsyncMock(return_value=mock_request.state.user_auth),
|
||||
),
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_role),
|
||||
),
|
||||
):
|
||||
# Act
|
||||
result = await require_financial_data_access(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grants_access_for_admin_role(self):
|
||||
"""
|
||||
GIVEN: User with admin role in organization (non-@openhands.dev email)
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Returns user_id (access granted)
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request_with_email(user_email='user@company.com')
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'admin'
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_auth',
|
||||
AsyncMock(return_value=mock_request.state.user_auth),
|
||||
),
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_role),
|
||||
),
|
||||
):
|
||||
# Act
|
||||
result = await require_financial_data_access(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_denies_access_for_member_role_without_openhands_email(self):
|
||||
"""
|
||||
GIVEN: User with member role (not admin/owner) and non-@openhands.dev email
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Raises 403 Forbidden
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request_with_email(user_email='user@company.com')
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_auth',
|
||||
AsyncMock(return_value=mock_request.state.user_auth),
|
||||
),
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=mock_role),
|
||||
),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_financial_data_access(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'admins, owners, or OpenHands' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_denies_access_for_non_member(self):
|
||||
"""
|
||||
GIVEN: User who is not a member of the organization
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Raises 403 Forbidden
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request_with_email(user_email='user@company.com')
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_auth',
|
||||
AsyncMock(return_value=mock_request.state.user_auth),
|
||||
),
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role',
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_financial_data_access(
|
||||
request=mock_request, org_id=org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'not a member' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_denies_access_when_not_authenticated(self):
|
||||
"""
|
||||
GIVEN: No user_id (not authenticated)
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Raises 401 Unauthorized
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
org_id = uuid4()
|
||||
mock_request = _create_mock_request_with_email()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_financial_data_access(
|
||||
request=mock_request, org_id=org_id, user_id=None
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'not authenticated' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_denies_access_when_api_key_org_mismatch(self):
|
||||
"""
|
||||
GIVEN: API key created for Org A, but user tries to access Org B
|
||||
WHEN: require_financial_data_access is called
|
||||
THEN: Raises 403 Forbidden with org mismatch message
|
||||
"""
|
||||
from server.auth.authorization import require_financial_data_access
|
||||
|
||||
# Arrange
|
||||
user_id = str(uuid4())
|
||||
api_key_org_id = uuid4() # Org A
|
||||
target_org_id = uuid4() # Org B
|
||||
mock_request = _create_mock_request_with_email(
|
||||
api_key_org_id=api_key_org_id, user_email='admin@openhands.dev'
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_financial_data_access(
|
||||
request=mock_request, org_id=target_org_id, user_id=user_id
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'API key is not authorized' in exc_info.value.detail
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from unittest import TestCase, mock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.github.github_view import GithubFactory, GithubIssue, get_oh_labels
|
||||
@@ -216,119 +215,3 @@ class TestGithubV1ConversationRouting(TestCase):
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
)
|
||||
mock_create_v0.assert_not_called()
|
||||
|
||||
|
||||
class TestGithubOrgRouting(TestCase):
|
||||
"""Test org routing for GitHub resolver conversations."""
|
||||
|
||||
def setUp(self):
|
||||
self.user_data = UserData(
|
||||
user_id=123, username='testuser', keycloak_user_id='test-keycloak-id'
|
||||
)
|
||||
self.raw_payload = Message(
|
||||
source=SourceType.GITHUB,
|
||||
message={
|
||||
'payload': {
|
||||
'action': 'opened',
|
||||
'issue': {'number': 42},
|
||||
}
|
||||
},
|
||||
)
|
||||
self.resolved_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
|
||||
def _create_github_issue(self):
|
||||
return GithubIssue(
|
||||
user_info=self.user_data,
|
||||
full_repo_name='ClaimedOrg/repo',
|
||||
issue_number=42,
|
||||
installation_id=456,
|
||||
conversation_id='',
|
||||
should_extract=True,
|
||||
send_summary_instruction=False,
|
||||
is_public_repo=True,
|
||||
raw_payload=self.raw_payload,
|
||||
uuid='test-uuid',
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.github.github_view.SaasConversationStore.get_resolver_instance'
|
||||
)
|
||||
@patch('integrations.github.github_view.resolve_org_for_repo')
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
async def test_v0_passes_resolver_org_id_to_get_resolver_instance(
|
||||
self, mock_v1_setting, mock_resolve_org, mock_get_resolver
|
||||
):
|
||||
"""V0 path creates store via get_resolver_instance with resolver_org_id."""
|
||||
# Arrange
|
||||
mock_v1_setting.return_value = False
|
||||
mock_resolve_org.return_value = self.resolved_org_id
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver.return_value = mock_store
|
||||
|
||||
github_issue = self._create_github_issue()
|
||||
|
||||
# Act
|
||||
await github_issue.initialize_new_conversation()
|
||||
|
||||
# Assert
|
||||
mock_resolve_org.assert_called_once_with(
|
||||
provider='github',
|
||||
full_repo_name='ClaimedOrg/repo',
|
||||
keycloak_user_id='test-keycloak-id',
|
||||
)
|
||||
# get_resolver_instance(config, user_id, resolver_org_id)
|
||||
args, _ = mock_get_resolver.call_args
|
||||
assert args[1] == 'test-keycloak-id'
|
||||
assert args[2] == self.resolved_org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.github.github_view.get_app_conversation_service')
|
||||
@patch('integrations.github.github_view.resolve_org_for_repo')
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
async def test_v1_passes_resolver_org_id_to_resolver_user_context(
|
||||
self, mock_v1_setting, mock_resolve_org, mock_get_service
|
||||
):
|
||||
"""V1 path passes resolved org_id to ResolverUserContext."""
|
||||
# Arrange
|
||||
mock_v1_setting.return_value = True
|
||||
mock_resolve_org.return_value = self.resolved_org_id
|
||||
|
||||
github_issue = self._create_github_issue()
|
||||
|
||||
# Initialize to set resolved_org_id and v1_enabled
|
||||
await github_issue.initialize_new_conversation()
|
||||
|
||||
# Assert
|
||||
assert github_issue.resolved_org_id == self.resolved_org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.github.github_view.SaasConversationStore.get_resolver_instance'
|
||||
)
|
||||
@patch('integrations.github.github_view.resolve_org_for_repo')
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
async def test_no_claim_passes_none_resolver_org_id(
|
||||
self, mock_v1_setting, mock_resolve_org, mock_get_resolver
|
||||
):
|
||||
"""When no claim exists, resolver_org_id is None (falls back to personal workspace)."""
|
||||
# Arrange
|
||||
mock_v1_setting.return_value = False
|
||||
mock_resolve_org.return_value = None
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver.return_value = mock_store
|
||||
|
||||
github_issue = self._create_github_issue()
|
||||
|
||||
# Act
|
||||
await github_issue.initialize_new_conversation()
|
||||
|
||||
# Assert
|
||||
args, _ = mock_get_resolver.call_args
|
||||
assert args[2] is None
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"""Tests for GitLab resolver org routing logic.
|
||||
|
||||
Tests that the GitLab resolver correctly resolves the target organization
|
||||
and passes resolver_org_id through V0 and V1 conversation paths.
|
||||
"""
|
||||
|
||||
from unittest import TestCase
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from integrations.gitlab.gitlab_view import GitlabIssue
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.types import UserData
|
||||
|
||||
|
||||
class TestGitlabOrgRouting(TestCase):
|
||||
"""Test org routing for GitLab resolver conversations."""
|
||||
|
||||
def setUp(self):
|
||||
self.user_data = UserData(
|
||||
user_id=123, username='testuser', keycloak_user_id='test-keycloak-id'
|
||||
)
|
||||
self.raw_payload = Message(
|
||||
source=SourceType.GITLAB,
|
||||
message={
|
||||
'payload': {
|
||||
'object_kind': 'issue',
|
||||
'object_attributes': {'action': 'open', 'iid': 42},
|
||||
}
|
||||
},
|
||||
)
|
||||
self.resolved_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
|
||||
def _create_gitlab_issue(self):
|
||||
return GitlabIssue(
|
||||
user_info=self.user_data,
|
||||
full_repo_name='ClaimedOrg/repo',
|
||||
issue_number=42,
|
||||
project_id=100,
|
||||
installation_id='install-123',
|
||||
conversation_id='',
|
||||
should_extract=True,
|
||||
send_summary_instruction=False,
|
||||
is_public_repo=True,
|
||||
raw_payload=self.raw_payload,
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=False,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.gitlab.gitlab_view.SaasConversationStore.get_resolver_instance'
|
||||
)
|
||||
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
|
||||
async def test_v0_passes_resolver_org_id_to_get_resolver_instance(
|
||||
self, mock_resolve_org, mock_get_resolver
|
||||
):
|
||||
"""V0 path creates store via get_resolver_instance with resolver_org_id."""
|
||||
# Arrange
|
||||
mock_resolve_org.return_value = self.resolved_org_id
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver.return_value = mock_store
|
||||
|
||||
gitlab_issue = self._create_gitlab_issue()
|
||||
|
||||
# Act
|
||||
await gitlab_issue.initialize_new_conversation()
|
||||
|
||||
# Assert
|
||||
mock_resolve_org.assert_called_once_with(
|
||||
provider='gitlab',
|
||||
full_repo_name='ClaimedOrg/repo',
|
||||
keycloak_user_id='test-keycloak-id',
|
||||
)
|
||||
# get_resolver_instance(config, user_id, resolver_org_id)
|
||||
args, _ = mock_get_resolver.call_args
|
||||
assert args[1] == 'test-keycloak-id'
|
||||
assert args[2] == self.resolved_org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.gitlab.gitlab_view.get_app_conversation_service')
|
||||
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
|
||||
async def test_v1_passes_resolver_org_id_to_resolver_user_context(
|
||||
self, mock_resolve_org, mock_get_service
|
||||
):
|
||||
"""V1 path passes resolved org_id to ResolverUserContext."""
|
||||
# Arrange
|
||||
mock_resolve_org.return_value = self.resolved_org_id
|
||||
|
||||
gitlab_issue = self._create_gitlab_issue()
|
||||
gitlab_issue.v1_enabled = True
|
||||
|
||||
# Initialize to set resolved_org_id
|
||||
await gitlab_issue.initialize_new_conversation()
|
||||
|
||||
# Assert
|
||||
assert gitlab_issue.resolved_org_id == self.resolved_org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.gitlab.gitlab_view.SaasConversationStore.get_resolver_instance'
|
||||
)
|
||||
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
|
||||
async def test_no_claim_passes_none_resolver_org_id(
|
||||
self, mock_resolve_org, mock_get_resolver
|
||||
):
|
||||
"""When no claim exists, resolver_org_id is None (falls back to personal workspace)."""
|
||||
# Arrange
|
||||
mock_resolve_org.return_value = None
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver.return_value = mock_store
|
||||
|
||||
gitlab_issue = self._create_gitlab_issue()
|
||||
|
||||
# Act
|
||||
await gitlab_issue.initialize_new_conversation()
|
||||
|
||||
# Assert
|
||||
args, _ = mock_get_resolver.call_args
|
||||
assert args[2] is None
|
||||
@@ -1,347 +0,0 @@
|
||||
"""Tests for Linear resolver org routing logic.
|
||||
|
||||
Tests that the LinearNewConversationView correctly resolves the target
|
||||
organization and passes resolver_org_id through the V0 conversation path.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from integrations.linear.linear_view import LinearNewConversationView
|
||||
from integrations.models import JobContext
|
||||
from storage.linear_user import LinearUser
|
||||
from storage.linear_workspace import LinearWorkspace
|
||||
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
|
||||
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
KEYCLOAK_USER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_linear_user():
|
||||
user = LinearUser()
|
||||
user.id = 1
|
||||
user.keycloak_user_id = KEYCLOAK_USER_ID
|
||||
user.linear_user_id = 'linear-user-123'
|
||||
user.linear_workspace_id = 1
|
||||
user.status = 'active'
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_linear_workspace():
|
||||
workspace = LinearWorkspace()
|
||||
workspace.id = 1
|
||||
workspace.name = 'test-workspace'
|
||||
workspace.linear_org_id = 'linear-org-123'
|
||||
workspace.admin_user_id = 'admin-123'
|
||||
workspace.webhook_secret = 'secret'
|
||||
workspace.svc_acc_email = 'svc@test.com'
|
||||
workspace.svc_acc_api_key = 'api-key'
|
||||
workspace.status = 'active'
|
||||
return workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_auth():
|
||||
auth = MagicMock(spec=UserAuth)
|
||||
auth.get_provider_tokens = AsyncMock(
|
||||
return_value={ProviderType.GITHUB: MagicMock()}
|
||||
)
|
||||
auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
|
||||
return auth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job_context():
|
||||
return JobContext(
|
||||
issue_id='issue-123',
|
||||
issue_key='PROJ-42',
|
||||
issue_title='Test issue',
|
||||
issue_description='Test description',
|
||||
user_msg='@openhands fix this',
|
||||
user_email='user@test.com',
|
||||
platform_user_id='linear-user-123',
|
||||
workspace_name='test-workspace',
|
||||
display_name='Test User',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def linear_view(job_context, mock_user_auth, mock_linear_user, mock_linear_workspace):
|
||||
return LinearNewConversationView(
|
||||
job_context=job_context,
|
||||
saas_user_auth=mock_user_auth,
|
||||
linear_user=mock_linear_user,
|
||||
linear_workspace=mock_linear_workspace,
|
||||
selected_repo='OpenHands/foo',
|
||||
conversation_id='',
|
||||
)
|
||||
|
||||
|
||||
class TestLinearV0OrgRouting:
|
||||
"""Test V0 conversation routing logic for Linear resolver."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
|
||||
)
|
||||
@patch('integrations.linear.linear_view.ProviderHandler')
|
||||
@patch(
|
||||
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
|
||||
@patch(
|
||||
'integrations.linear.linear_view.integration_store',
|
||||
)
|
||||
async def test_v0_passes_resolver_org_id_to_get_resolver_instance(
|
||||
self,
|
||||
mock_integration_store,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_provider_handler_cls,
|
||||
mock_resolve_org,
|
||||
linear_view,
|
||||
):
|
||||
"""V0 path should resolve org and pass resolver_org_id to get_resolver_instance."""
|
||||
# Arrange
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.git_provider = ProviderType.GITHUB
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
|
||||
mock_provider_handler_cls.return_value = mock_handler
|
||||
|
||||
mock_resolve_org.return_value = CLAIMING_ORG_ID
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_integration_store.create_conversation = AsyncMock()
|
||||
|
||||
mock_jinja = MagicMock()
|
||||
|
||||
# Act
|
||||
with patch.object(
|
||||
linear_view,
|
||||
'_get_instructions',
|
||||
new_callable=AsyncMock,
|
||||
return_value=('instructions', 'user_msg'),
|
||||
):
|
||||
await linear_view.create_or_update_conversation(mock_jinja)
|
||||
|
||||
# Assert
|
||||
mock_resolve_org.assert_called_once_with(
|
||||
provider='github',
|
||||
full_repo_name='OpenHands/foo',
|
||||
keycloak_user_id=KEYCLOAK_USER_ID,
|
||||
)
|
||||
call_args = mock_get_resolver_instance.call_args
|
||||
assert call_args[0][1] == KEYCLOAK_USER_ID
|
||||
assert call_args[0][2] == CLAIMING_ORG_ID
|
||||
saved_metadata = mock_store.save_metadata.call_args[0][0]
|
||||
assert saved_metadata.trigger == ConversationTrigger.LINEAR
|
||||
assert saved_metadata.git_provider == ProviderType.GITHUB
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
|
||||
)
|
||||
@patch('integrations.linear.linear_view.ProviderHandler')
|
||||
@patch(
|
||||
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
|
||||
@patch(
|
||||
'integrations.linear.linear_view.integration_store',
|
||||
)
|
||||
async def test_v0_passes_none_when_no_claim(
|
||||
self,
|
||||
mock_integration_store,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_provider_handler_cls,
|
||||
mock_resolve_org,
|
||||
linear_view,
|
||||
):
|
||||
"""When no claim exists, resolver_org_id should be None (personal workspace)."""
|
||||
# Arrange
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.git_provider = ProviderType.GITHUB
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
|
||||
mock_provider_handler_cls.return_value = mock_handler
|
||||
|
||||
mock_resolve_org.return_value = None
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_integration_store.create_conversation = AsyncMock()
|
||||
|
||||
mock_jinja = MagicMock()
|
||||
|
||||
# Act
|
||||
with patch.object(
|
||||
linear_view,
|
||||
'_get_instructions',
|
||||
new_callable=AsyncMock,
|
||||
return_value=('instructions', 'user_msg'),
|
||||
):
|
||||
await linear_view.create_or_update_conversation(mock_jinja)
|
||||
|
||||
# Assert
|
||||
call_args = mock_get_resolver_instance.call_args
|
||||
assert call_args[0][2] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
|
||||
)
|
||||
@patch(
|
||||
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
|
||||
@patch(
|
||||
'integrations.linear.linear_view.integration_store',
|
||||
)
|
||||
async def test_no_provider_tokens_skips_org_resolution(
|
||||
self,
|
||||
mock_integration_store,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_resolve_org,
|
||||
linear_view,
|
||||
mock_user_auth,
|
||||
):
|
||||
"""When provider tokens are None, org resolution should be skipped."""
|
||||
# Arrange
|
||||
mock_user_auth.get_provider_tokens = AsyncMock(return_value=None)
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_integration_store.create_conversation = AsyncMock()
|
||||
|
||||
mock_jinja = MagicMock()
|
||||
|
||||
# Act
|
||||
with patch.object(
|
||||
linear_view,
|
||||
'_get_instructions',
|
||||
new_callable=AsyncMock,
|
||||
return_value=('instructions', 'user_msg'),
|
||||
):
|
||||
await linear_view.create_or_update_conversation(mock_jinja)
|
||||
|
||||
# Assert
|
||||
mock_resolve_org.assert_not_called()
|
||||
call_args = mock_get_resolver_instance.call_args
|
||||
assert call_args[0][2] is None
|
||||
saved_metadata = mock_store.save_metadata.call_args[0][0]
|
||||
assert saved_metadata.git_provider is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
|
||||
)
|
||||
@patch('integrations.linear.linear_view.ProviderHandler')
|
||||
@patch(
|
||||
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
|
||||
@patch(
|
||||
'integrations.linear.linear_view.integration_store',
|
||||
)
|
||||
async def test_verify_repo_provider_failure_falls_back_to_personal_workspace(
|
||||
self,
|
||||
mock_integration_store,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_provider_handler_cls,
|
||||
mock_resolve_org,
|
||||
linear_view,
|
||||
):
|
||||
"""When verify_repo_provider fails, should fall back to personal workspace."""
|
||||
# Arrange
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.verify_repo_provider = AsyncMock(
|
||||
side_effect=Exception('Repository not found')
|
||||
)
|
||||
mock_provider_handler_cls.return_value = mock_handler
|
||||
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_integration_store.create_conversation = AsyncMock()
|
||||
|
||||
mock_jinja = MagicMock()
|
||||
|
||||
# Act
|
||||
with patch.object(
|
||||
linear_view,
|
||||
'_get_instructions',
|
||||
new_callable=AsyncMock,
|
||||
return_value=('instructions', 'user_msg'),
|
||||
):
|
||||
await linear_view.create_or_update_conversation(mock_jinja)
|
||||
|
||||
# Assert - org resolution should be skipped, conversation created in personal workspace
|
||||
mock_resolve_org.assert_not_called()
|
||||
call_args = mock_get_resolver_instance.call_args
|
||||
assert call_args[0][2] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
|
||||
)
|
||||
@patch('integrations.linear.linear_view.ProviderHandler')
|
||||
@patch(
|
||||
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
|
||||
@patch(
|
||||
'integrations.linear.linear_view.integration_store',
|
||||
)
|
||||
async def test_resolve_org_failure_falls_back_to_personal_workspace(
|
||||
self,
|
||||
mock_integration_store,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_provider_handler_cls,
|
||||
mock_resolve_org,
|
||||
linear_view,
|
||||
):
|
||||
"""When resolve_org_for_repo fails, should fall back to personal workspace."""
|
||||
# Arrange
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.git_provider = ProviderType.GITHUB
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
|
||||
mock_provider_handler_cls.return_value = mock_handler
|
||||
|
||||
mock_resolve_org.side_effect = Exception('Database connection failed')
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_integration_store.create_conversation = AsyncMock()
|
||||
|
||||
mock_jinja = MagicMock()
|
||||
|
||||
# Act
|
||||
with patch.object(
|
||||
linear_view,
|
||||
'_get_instructions',
|
||||
new_callable=AsyncMock,
|
||||
return_value=('instructions', 'user_msg'),
|
||||
):
|
||||
await linear_view.create_or_update_conversation(mock_jinja)
|
||||
|
||||
# Assert - conversation should be created with resolver_org_id=None
|
||||
call_args = mock_get_resolver_instance.call_args
|
||||
assert call_args[0][2] is None
|
||||
@@ -2576,304 +2576,3 @@ class TestBudgetPayloadHandling:
|
||||
'max_budget_in_team' in json_payload
|
||||
), 'max_budget_in_team should be in payload when set to a value'
|
||||
assert json_payload['max_budget_in_team'] == 75.0
|
||||
|
||||
|
||||
class TestGetTeamMembersFinancialData:
|
||||
"""Test cases for _get_team_members_financial_data method."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_http_client(self):
|
||||
"""Create a mock HTTP client."""
|
||||
return AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_financial_data_for_all_team_members(self, mock_http_client):
|
||||
"""
|
||||
GIVEN: Team with multiple members having financial data
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Returns dict with team info and member data
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
'team_info': {'team_id': 'test-team', 'max_budget': 500.0, 'spend': 125.5},
|
||||
'team_memberships': [
|
||||
{
|
||||
'user_id': 'user-1',
|
||||
'spend': 50.0,
|
||||
'max_budget_in_team': 200.0,
|
||||
},
|
||||
{
|
||||
'user_id': 'user-2',
|
||||
'spend': 75.5,
|
||||
'max_budget_in_team': 150.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_http_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
# Act
|
||||
result = await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'test-team'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result['team_max_budget'] == 500.0
|
||||
assert result['team_spend'] == 125.5
|
||||
assert len(result['members']) == 2
|
||||
# Both users have individual budgets (max_budget_in_team is set)
|
||||
assert result['members']['user-1'] == {
|
||||
'spend': 50.0,
|
||||
'max_budget': 200.0,
|
||||
'uses_shared_budget': False,
|
||||
}
|
||||
assert result['members']['user-2'] == {
|
||||
'spend': 75.5,
|
||||
'max_budget': 150.0,
|
||||
'uses_shared_budget': False,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_dict_when_litellm_not_configured(
|
||||
self, mock_http_client
|
||||
):
|
||||
"""
|
||||
GIVEN: LiteLLM API key or URL not configured
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Returns empty dict
|
||||
"""
|
||||
# Arrange - no patching, so LITE_LLM_API_KEY/URL are None
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', None):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', None):
|
||||
# Act
|
||||
result = await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'test-team'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == {}
|
||||
mock_http_client.get.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_dict_when_team_not_found(self, mock_http_client):
|
||||
"""
|
||||
GIVEN: Team does not exist in LiteLLM
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Returns empty dict
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
'Not found', request=MagicMock(), response=mock_response
|
||||
)
|
||||
mock_http_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
# Act & Assert
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'nonexistent-team'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_members_when_team_has_no_members(
|
||||
self, mock_http_client
|
||||
):
|
||||
"""
|
||||
GIVEN: Team exists but has no members
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Returns structure with empty members dict
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
'team_info': {'team_id': 'empty-team', 'max_budget': 100.0, 'spend': 0},
|
||||
'team_memberships': [],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_http_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
# Act
|
||||
result = await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'empty-team'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result['team_max_budget'] == 100.0
|
||||
assert result['team_spend'] == 0
|
||||
assert result['members'] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_team_budget_when_member_budget_missing(
|
||||
self, mock_http_client
|
||||
):
|
||||
"""
|
||||
GIVEN: Team with shared budget, members without individual max_budget_in_team
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Falls back to team_info.max_budget for members without individual budget
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
'team_info': {'team_id': 'test-team', 'max_budget': 500.0, 'spend': 150.0},
|
||||
'team_memberships': [
|
||||
{
|
||||
'user_id': 'user-no-individual-budget',
|
||||
'spend': 50.0,
|
||||
# No max_budget_in_team - should fall back to team budget
|
||||
},
|
||||
{
|
||||
'user_id': 'user-with-individual-budget',
|
||||
'spend': 75.0,
|
||||
'max_budget_in_team': 200.0, # Individual budget set
|
||||
},
|
||||
{
|
||||
'user_id': 'user-null-budget',
|
||||
'spend': 25.0,
|
||||
'max_budget_in_team': None, # Explicit null - fall back to team
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_http_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
# Act
|
||||
result = await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'test-team'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result['team_max_budget'] == 500.0
|
||||
assert result['team_spend'] == 150.0
|
||||
members = result['members']
|
||||
assert members['user-no-individual-budget'] == {
|
||||
'spend': 50.0,
|
||||
'max_budget': 500.0,
|
||||
'uses_shared_budget': True,
|
||||
}
|
||||
assert members['user-with-individual-budget'] == {
|
||||
'spend': 75.0,
|
||||
'max_budget': 200.0,
|
||||
'uses_shared_budget': False,
|
||||
}
|
||||
assert members['user-null-budget'] == {
|
||||
'spend': 25.0,
|
||||
'max_budget': 500.0,
|
||||
'uses_shared_budget': True,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_defaults_when_no_budget_data_available(self, mock_http_client):
|
||||
"""
|
||||
GIVEN: Team without budget and members without individual budgets
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Returns default values (spend=0, max_budget=None)
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
'team_info': {'team_id': 'test-team'}, # No max_budget at team level
|
||||
'team_memberships': [
|
||||
{
|
||||
'user_id': 'user-no-data',
|
||||
# No spend or max_budget_in_team
|
||||
},
|
||||
{
|
||||
'user_id': 'user-null-spend',
|
||||
'spend': None, # Explicit null
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_http_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
# Act
|
||||
result = await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'test-team'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result['team_max_budget'] is None
|
||||
assert result['team_spend'] == 0
|
||||
members = result['members']
|
||||
# Both users fall back to team budget (which is None)
|
||||
assert members['user-no-data'] == {
|
||||
'spend': 0,
|
||||
'max_budget': None,
|
||||
'uses_shared_budget': True,
|
||||
}
|
||||
assert members['user-null-spend'] == {
|
||||
'spend': 0,
|
||||
'max_budget': None,
|
||||
'uses_shared_budget': True,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_members_without_user_id(self, mock_http_client):
|
||||
"""
|
||||
GIVEN: Team with members, some missing user_id
|
||||
WHEN: _get_team_members_financial_data is called
|
||||
THEN: Skips members without user_id
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
'team_info': {'team_id': 'test-team', 'max_budget': 300.0, 'spend': 105.0},
|
||||
'team_memberships': [
|
||||
{
|
||||
'user_id': 'valid-user',
|
||||
'spend': 25.0,
|
||||
'max_budget_in_team': 100.0,
|
||||
},
|
||||
{
|
||||
# Missing user_id
|
||||
'spend': 50.0,
|
||||
'max_budget_in_team': 200.0,
|
||||
},
|
||||
{
|
||||
'user_id': None, # Explicit null
|
||||
'spend': 30.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_http_client.get.return_value = mock_response
|
||||
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
|
||||
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
|
||||
# Act
|
||||
result = await LiteLlmManager._get_team_members_financial_data(
|
||||
mock_http_client, 'test-team'
|
||||
)
|
||||
|
||||
# Assert - only valid user should be included
|
||||
assert result['team_max_budget'] == 300.0
|
||||
assert result['team_spend'] == 105.0
|
||||
assert len(result['members']) == 1
|
||||
assert 'valid-user' in result['members']
|
||||
assert result['members']['valid-user'] == {
|
||||
'spend': 25.0,
|
||||
'max_budget': 100.0,
|
||||
'uses_shared_budget': False,
|
||||
}
|
||||
|
||||
@@ -32,12 +32,11 @@ class TestLogOutput:
|
||||
|
||||
logger.info('Test message')
|
||||
output = json.loads(string_io.getvalue())
|
||||
assert output['message'] == 'Test message'
|
||||
assert output['severity'] == 'INFO'
|
||||
assert output['ts'] == FROZEN_TIMESTAMP
|
||||
assert output['module'] == 'test_logger'
|
||||
assert output['funcName'] == 'test_info'
|
||||
assert 'lineno' in output
|
||||
assert output == {
|
||||
'message': 'Test message',
|
||||
'severity': 'INFO',
|
||||
'ts': FROZEN_TIMESTAMP,
|
||||
}
|
||||
|
||||
@freeze_time(FROZEN_TIMESTAMP)
|
||||
def test_error(self, log_output):
|
||||
@@ -45,12 +44,11 @@ class TestLogOutput:
|
||||
|
||||
logger.error('Test message')
|
||||
output = json.loads(string_io.getvalue())
|
||||
assert output['message'] == 'Test message'
|
||||
assert output['severity'] == 'ERROR'
|
||||
assert output['ts'] == FROZEN_TIMESTAMP
|
||||
assert output['module'] == 'test_logger'
|
||||
assert output['funcName'] == 'test_error'
|
||||
assert 'lineno' in output
|
||||
assert output == {
|
||||
'message': 'Test message',
|
||||
'severity': 'ERROR',
|
||||
'ts': FROZEN_TIMESTAMP,
|
||||
}
|
||||
|
||||
@freeze_time(FROZEN_TIMESTAMP)
|
||||
def test_extra_fields(self, log_output):
|
||||
@@ -58,13 +56,12 @@ class TestLogOutput:
|
||||
|
||||
logger.info('Test message', extra={'key': '..val..'})
|
||||
output = json.loads(string_io.getvalue())
|
||||
assert output['key'] == '..val..'
|
||||
assert output['message'] == 'Test message'
|
||||
assert output['severity'] == 'INFO'
|
||||
assert output['ts'] == FROZEN_TIMESTAMP
|
||||
assert output['module'] == 'test_logger'
|
||||
assert output['funcName'] == 'test_extra_fields'
|
||||
assert 'lineno' in output
|
||||
assert output == {
|
||||
'key': '..val..',
|
||||
'message': 'Test message',
|
||||
'severity': 'INFO',
|
||||
'ts': FROZEN_TIMESTAMP,
|
||||
}
|
||||
|
||||
def test_format_stack(self):
|
||||
stack = (
|
||||
@@ -287,12 +284,11 @@ class TestLogOutput:
|
||||
):
|
||||
openhands_logger.info('The secret key was supersecretvalue')
|
||||
output = json.loads(string_io.getvalue())
|
||||
assert output['message'] == 'The secret key was ******'
|
||||
assert output['severity'] == 'INFO'
|
||||
assert output['ts'] == FROZEN_TIMESTAMP
|
||||
assert 'module' in output
|
||||
assert 'funcName' in output
|
||||
assert 'lineno' in output
|
||||
assert output == {
|
||||
'message': 'The secret key was ******',
|
||||
'severity': 'INFO',
|
||||
'ts': FROZEN_TIMESTAMP,
|
||||
}
|
||||
|
||||
@freeze_time(FROZEN_TIMESTAMP)
|
||||
def test_console_serializer_uses_ts_not_timestamp(self):
|
||||
|
||||
@@ -41,157 +41,191 @@ class TestRouterPrefixes:
|
||||
assert accept_router.prefix == '/api/organizations/members/invite'
|
||||
|
||||
|
||||
class TestAcceptInvitationGetEndpoint:
|
||||
"""Test cases for the GET accept invitation endpoint (redirect flow)."""
|
||||
|
||||
def test_get_accept_redirects_to_home_with_token(self, client):
|
||||
"""Test that GET request always redirects to home with invitation_token.
|
||||
|
||||
The GET endpoint is accessed via the link in invitation emails.
|
||||
It always redirects to the home page with the token, allowing the
|
||||
frontend to handle acceptance via a modal with authenticated POST.
|
||||
"""
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
location = response.headers.get('location', '')
|
||||
assert '/?invitation_token=inv-test-token-123' in location
|
||||
|
||||
|
||||
class TestAcceptInvitationPostEndpoint:
|
||||
"""Test cases for the POST accept invitation endpoint (authenticated flow)."""
|
||||
class TestAcceptInvitationEndpoint:
|
||||
"""Test cases for the accept invitation endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def auth_app(self):
|
||||
"""Create a FastAPI app with dependency overrides for authenticated tests."""
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(accept_router)
|
||||
|
||||
# Override the get_user_id dependency
|
||||
app.dependency_overrides[get_user_id] = (
|
||||
lambda: '87654321-4321-8765-4321-876543218765'
|
||||
def mock_user_auth(self):
|
||||
"""Create a mock user auth."""
|
||||
user_auth = MagicMock()
|
||||
user_auth.get_user_id = AsyncMock(
|
||||
return_value='87654321-4321-8765-4321-876543218765'
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(self, auth_app):
|
||||
"""Create a test client with authentication dependency overrides."""
|
||||
return TestClient(auth_app)
|
||||
return user_auth
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_accept_success_returns_org_details(self, auth_client):
|
||||
"""Test that successful POST acceptance returns organization details."""
|
||||
from uuid import UUID
|
||||
async def test_accept_unauthenticated_redirects_to_login(self, client):
|
||||
"""Test that unauthenticated users are redirected to login with invitation token."""
|
||||
with patch(
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert '/login?invitation_token=inv-test-token-123' in response.headers.get(
|
||||
'location', ''
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_authenticated_success_redirects_home(
|
||||
self, client, mock_user_auth
|
||||
):
|
||||
"""Test that successful acceptance redirects to home page."""
|
||||
mock_invitation = MagicMock()
|
||||
mock_invitation.org_id = UUID('12345678-1234-5678-1234-567812345678')
|
||||
mock_invitation.role_id = 3
|
||||
|
||||
mock_org = MagicMock()
|
||||
mock_org.name = 'Test Organization'
|
||||
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_invitation,
|
||||
),
|
||||
):
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
location = response.headers.get('location', '')
|
||||
assert location.endswith('/')
|
||||
assert 'invitation_expired' not in location
|
||||
assert 'invitation_invalid' not in location
|
||||
assert 'email_mismatch' not in location
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_expired_invitation_redirects_with_flag(
|
||||
self, client, mock_user_auth
|
||||
):
|
||||
"""Test that expired invitation redirects with invitation_expired=true."""
|
||||
with (
|
||||
patch(
|
||||
'server.routes.org_invitations.OrgStore.get_org_by_id',
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_org,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.RoleStore.get_role_by_id',
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_role,
|
||||
side_effect=InvitationExpiredError(),
|
||||
),
|
||||
):
|
||||
response = auth_client.post(
|
||||
'/api/organizations/members/invite/accept',
|
||||
json={'token': 'inv-test-token-123'},
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['success'] is True
|
||||
assert data['org_id'] == '12345678-1234-5678-1234-567812345678'
|
||||
assert data['org_name'] == 'Test Organization'
|
||||
assert data['role'] == 'member'
|
||||
assert response.status_code == 302
|
||||
assert 'invitation_expired=true' in response.headers.get('location', '')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_accept_expired_returns_400(self, auth_client):
|
||||
"""Test that expired invitation returns 400 with detail."""
|
||||
with patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=InvitationExpiredError(),
|
||||
async def test_accept_invalid_invitation_redirects_with_flag(
|
||||
self, client, mock_user_auth
|
||||
):
|
||||
"""Test that invalid invitation redirects with invitation_invalid=true."""
|
||||
with (
|
||||
patch(
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=InvitationInvalidError(),
|
||||
),
|
||||
):
|
||||
response = auth_client.post(
|
||||
'/api/organizations/members/invite/accept',
|
||||
json={'token': 'inv-test-token-123'},
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()['detail'] == 'invitation_expired'
|
||||
assert response.status_code == 302
|
||||
assert 'invitation_invalid=true' in response.headers.get('location', '')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_accept_invalid_returns_400(self, auth_client):
|
||||
"""Test that invalid invitation returns 400 with detail."""
|
||||
with patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=InvitationInvalidError(),
|
||||
async def test_accept_already_member_redirects_with_flag(
|
||||
self, client, mock_user_auth
|
||||
):
|
||||
"""Test that already member error redirects with already_member=true."""
|
||||
with (
|
||||
patch(
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=UserAlreadyMemberError(),
|
||||
),
|
||||
):
|
||||
response = auth_client.post(
|
||||
'/api/organizations/members/invite/accept',
|
||||
json={'token': 'inv-test-token-123'},
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()['detail'] == 'invitation_invalid'
|
||||
assert response.status_code == 302
|
||||
assert 'already_member=true' in response.headers.get('location', '')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_accept_already_member_returns_409(self, auth_client):
|
||||
"""Test that already member error returns 409 with detail."""
|
||||
with patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=UserAlreadyMemberError(),
|
||||
async def test_accept_email_mismatch_redirects_with_flag(
|
||||
self, client, mock_user_auth
|
||||
):
|
||||
"""Test that email mismatch error redirects with email_mismatch=true."""
|
||||
with (
|
||||
patch(
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=EmailMismatchError(),
|
||||
),
|
||||
):
|
||||
response = auth_client.post(
|
||||
'/api/organizations/members/invite/accept',
|
||||
json={'token': 'inv-test-token-123'},
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()['detail'] == 'already_member'
|
||||
assert response.status_code == 302
|
||||
assert 'email_mismatch=true' in response.headers.get('location', '')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_accept_email_mismatch_returns_403(self, auth_client):
|
||||
"""Test that email mismatch error returns 403 with detail."""
|
||||
with patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=EmailMismatchError(),
|
||||
async def test_accept_unexpected_error_redirects_with_flag(
|
||||
self, client, mock_user_auth
|
||||
):
|
||||
"""Test that unexpected errors redirect with invitation_error=true."""
|
||||
with (
|
||||
patch(
|
||||
'server.routes.org_invitations.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception('Unexpected error'),
|
||||
),
|
||||
):
|
||||
response = auth_client.post(
|
||||
'/api/organizations/members/invite/accept',
|
||||
json={'token': 'inv-test-token-123'},
|
||||
response = client.get(
|
||||
'/api/organizations/members/invite/accept?token=inv-test-token-123',
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()['detail'] == 'email_mismatch'
|
||||
assert response.status_code == 302
|
||||
assert 'invitation_error=true' in response.headers.get('location', '')
|
||||
|
||||
|
||||
class TestCreateInvitationBatchEndpoint:
|
||||
|
||||
@@ -214,125 +214,3 @@ class TestGetInstance:
|
||||
# Assert
|
||||
assert store.user_id == user_id
|
||||
assert store.org_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resolver_instance_passes_resolver_org_id(self):
|
||||
"""Verify get_resolver_instance forwards resolver_org_id to the store."""
|
||||
# Arrange
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
resolver_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
mock_user = MagicMock(spec=User)
|
||||
mock_user.current_org_id = UUID(user_id)
|
||||
mock_config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
with patch(
|
||||
'storage.saas_conversation_store.UserStore.get_user_by_id',
|
||||
AsyncMock(return_value=mock_user),
|
||||
), patch('storage.saas_conversation_store.session_maker'):
|
||||
# Act
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
mock_config, user_id, resolver_org_id=resolver_org_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert store.resolver_org_id == resolver_org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_instance_does_not_have_resolver_org_id(self):
|
||||
"""Verify get_instance does not set resolver_org_id (it's not a resolver path)."""
|
||||
# Arrange
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
mock_user = MagicMock(spec=User)
|
||||
mock_user.current_org_id = UUID(user_id)
|
||||
mock_config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
with patch(
|
||||
'storage.saas_conversation_store.UserStore.get_user_by_id',
|
||||
AsyncMock(return_value=mock_user),
|
||||
), patch('storage.saas_conversation_store.session_maker'):
|
||||
# Act
|
||||
store = await SaasConversationStore.get_instance(mock_config, user_id)
|
||||
|
||||
# Assert
|
||||
assert store.resolver_org_id is None
|
||||
|
||||
|
||||
class TestResolverOrgIdRouting:
|
||||
"""Tests for resolver_org_id overriding org_id in save_metadata."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_metadata_uses_resolver_org_id_over_default(self, session_maker):
|
||||
"""When resolver_org_id is set, save_metadata stores it instead of the default org_id."""
|
||||
# Arrange
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
default_org_id = UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
resolver_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
store = SaasConversationStore(
|
||||
user_id, default_org_id, session_maker, resolver_org_id=resolver_org_id
|
||||
)
|
||||
metadata = ConversationMetadata(
|
||||
conversation_id='resolver-routed-conv',
|
||||
user_id=user_id,
|
||||
selected_repository='ClaimedOrg/repo',
|
||||
selected_branch=None,
|
||||
created_at=datetime.now(UTC),
|
||||
last_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
# Act
|
||||
await store.save_metadata(metadata)
|
||||
|
||||
# Assert - verify the SaaS metadata record has the resolver org, not the default
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
with session_maker() as session:
|
||||
saas_record = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(
|
||||
StoredConversationMetadataSaas.conversation_id
|
||||
== 'resolver-routed-conv'
|
||||
)
|
||||
.first()
|
||||
)
|
||||
assert saas_record is not None
|
||||
assert saas_record.org_id == resolver_org_id
|
||||
assert saas_record.org_id != default_org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_metadata_uses_default_org_when_no_resolver_org(
|
||||
self, session_maker
|
||||
):
|
||||
"""When resolver_org_id is None, save_metadata uses the default org_id."""
|
||||
# Arrange
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
default_org_id = UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
store = SaasConversationStore(user_id, default_org_id, session_maker)
|
||||
metadata = ConversationMetadata(
|
||||
conversation_id='default-org-conv',
|
||||
user_id=user_id,
|
||||
selected_repository='PersonalOrg/repo',
|
||||
selected_branch=None,
|
||||
created_at=datetime.now(UTC),
|
||||
last_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
# Act
|
||||
await store.save_metadata(metadata)
|
||||
|
||||
# Assert
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
with session_maker() as session:
|
||||
saas_record = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(
|
||||
StoredConversationMetadataSaas.conversation_id == 'default-org-conv'
|
||||
)
|
||||
.first()
|
||||
)
|
||||
assert saas_record is not None
|
||||
assert saas_record.org_id == default_org_id
|
||||
|
||||
@@ -535,99 +535,6 @@ async def test_store_does_not_update_org_mcp_config(
|
||||
assert org.mcp_config is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_skips_ensure_api_key_for_non_openhands_model_without_base_url(
|
||||
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
|
||||
):
|
||||
"""When saving a non-OpenHands model with no base URL (basic view BYOR),
|
||||
_ensure_api_key should NOT be called, preserving the user's custom API key.
|
||||
|
||||
This is the primary bug fix: users selecting e.g. OpenAI in basic view and
|
||||
providing their own API key should not have it overwritten by a proxy key.
|
||||
"""
|
||||
# Arrange
|
||||
fixture = org_with_multiple_members_fixture
|
||||
admin_user_id = str(fixture['admin_user_id'])
|
||||
store = SaasSettingsStore(admin_user_id, mock_config)
|
||||
|
||||
custom_api_key = 'sk-user-custom-openai-key'
|
||||
settings = DataSettings(
|
||||
llm_model='openai/gpt-5.2',
|
||||
llm_base_url=None, # Basic view: no base URL provided
|
||||
llm_api_key=SecretStr(custom_api_key),
|
||||
)
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch('storage.saas_settings_store.a_session_maker', async_session_maker),
|
||||
patch.object(store, '_ensure_api_key', new_callable=AsyncMock) as mock_ensure,
|
||||
):
|
||||
await store.store(settings)
|
||||
|
||||
# Assert
|
||||
mock_ensure.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_calls_ensure_api_key_for_openhands_model_without_base_url(
|
||||
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
|
||||
):
|
||||
"""When saving an OpenHands model with no base URL, _ensure_api_key should
|
||||
still be called to generate/verify the proxy key.
|
||||
|
||||
This guards the edge case of switching from a non-OpenHands provider to
|
||||
OpenHands in basic view, where a stale BYOR key needs to be replaced.
|
||||
"""
|
||||
# Arrange
|
||||
fixture = org_with_multiple_members_fixture
|
||||
admin_user_id = str(fixture['admin_user_id'])
|
||||
store = SaasSettingsStore(admin_user_id, mock_config)
|
||||
|
||||
settings = DataSettings(
|
||||
llm_model='openhands/claude-opus-4-5-20251101',
|
||||
llm_base_url=None,
|
||||
llm_api_key=SecretStr('sk-stale-openai-key'),
|
||||
)
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch('storage.saas_settings_store.a_session_maker', async_session_maker),
|
||||
patch.object(store, '_ensure_api_key', new_callable=AsyncMock) as mock_ensure,
|
||||
):
|
||||
await store.store(settings)
|
||||
|
||||
# Assert
|
||||
mock_ensure.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_calls_ensure_api_key_when_base_url_is_litellm_proxy(
|
||||
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
|
||||
):
|
||||
"""When the base URL is explicitly the LiteLLM proxy, _ensure_api_key should
|
||||
be called regardless of the model type."""
|
||||
# Arrange
|
||||
fixture = org_with_multiple_members_fixture
|
||||
admin_user_id = str(fixture['admin_user_id'])
|
||||
store = SaasSettingsStore(admin_user_id, mock_config)
|
||||
|
||||
settings = DataSettings(
|
||||
llm_model='openai/gpt-5.2',
|
||||
llm_base_url=LITE_LLM_API_URL,
|
||||
llm_api_key=SecretStr('sk-some-key'),
|
||||
)
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch('storage.saas_settings_store.a_session_maker', async_session_maker),
|
||||
patch.object(store, '_ensure_api_key', new_callable=AsyncMock) as mock_ensure,
|
||||
):
|
||||
await store.store(settings)
|
||||
|
||||
# Assert
|
||||
mock_ensure.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_returns_user_specific_mcp_config(
|
||||
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
|
||||
|
||||
@@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import jwt
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from keycloak.exceptions import KeycloakPostError
|
||||
from pydantic import SecretStr
|
||||
from server.auth.auth_error import (
|
||||
AuthError,
|
||||
@@ -24,7 +23,6 @@ from storage.api_key_store import ApiKeyValidationResult
|
||||
from storage.user_authorization import UserAuthorizationType
|
||||
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.server.types import SessionExpiredError
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
@@ -912,66 +910,3 @@ async def test_saas_user_auth_from_signed_token_domain_blocking_inactive(mock_co
|
||||
mock_user_auth_store.get_authorization_type.assert_called_once_with(
|
||||
'user@colsch.us', None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_saas_user_auth_from_bearer_session_expired():
|
||||
"""Test that saas_user_auth_from_bearer raises SessionExpiredError on Keycloak session expiration."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {'Authorization': 'Bearer test_api_key'}
|
||||
|
||||
with patch('server.auth.saas_user_auth.ApiKeyStore') as mock_api_key_store_cls:
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store_cls.get_instance.return_value = mock_api_key_store
|
||||
mock_api_key_store.validate_api_key = AsyncMock(
|
||||
return_value=ApiKeyValidationResult(
|
||||
user_id='test_user_id',
|
||||
org_id=uuid.uuid4(),
|
||||
key_id=1,
|
||||
key_name='test_key',
|
||||
)
|
||||
)
|
||||
|
||||
with patch('server.auth.saas_user_auth.token_manager') as mock_token_manager:
|
||||
# Simulate Keycloak session expired error
|
||||
mock_token_manager.load_offline_token = AsyncMock(
|
||||
side_effect=KeycloakPostError(
|
||||
error_message=b'{"error":"invalid_grant","error_description":"Offline user session not found"}'
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(SessionExpiredError) as exc_info:
|
||||
await saas_user_auth_from_bearer(mock_request)
|
||||
|
||||
assert 'session has expired' in str(exc_info.value).lower()
|
||||
assert 'https://app.all-hands.dev' in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_saas_user_auth_from_bearer_other_keycloak_error():
|
||||
"""Test that saas_user_auth_from_bearer raises BearerTokenError on other Keycloak errors."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {'Authorization': 'Bearer test_api_key'}
|
||||
|
||||
with patch('server.auth.saas_user_auth.ApiKeyStore') as mock_api_key_store_cls:
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store_cls.get_instance.return_value = mock_api_key_store
|
||||
mock_api_key_store.validate_api_key = AsyncMock(
|
||||
return_value=ApiKeyValidationResult(
|
||||
user_id='test_user_id',
|
||||
org_id=uuid.uuid4(),
|
||||
key_id=1,
|
||||
key_name='test_key',
|
||||
)
|
||||
)
|
||||
|
||||
with patch('server.auth.saas_user_auth.token_manager') as mock_token_manager:
|
||||
# Simulate a different Keycloak error (not session expired)
|
||||
mock_token_manager.load_offline_token = AsyncMock(
|
||||
side_effect=KeycloakPostError(
|
||||
error_message=b'{"error":"server_error","error_description":"Internal server error"}'
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(BearerTokenError):
|
||||
await saas_user_auth_from_bearer(mock_request)
|
||||
|
||||
@@ -135,19 +135,14 @@ class TestRepoVerificationHandling:
|
||||
|
||||
@patch('integrations.slack.slack_manager.sio')
|
||||
@patch.object(SlackManager, 'send_message', new_callable=AsyncMock)
|
||||
async def test_no_repo_mentioned_shows_button_and_dropdown(
|
||||
async def test_no_repo_mentioned_shows_external_selector(
|
||||
self,
|
||||
mock_send_message,
|
||||
mock_sio,
|
||||
slack_manager,
|
||||
slack_new_conversation_view,
|
||||
):
|
||||
"""Test that when no repo is mentioned, a button and dropdown are shown.
|
||||
|
||||
The form shows:
|
||||
1. A "No Repository" button - immediately clickable without loading
|
||||
2. An external_select dropdown - for searching repositories dynamically
|
||||
"""
|
||||
"""Test that when no repo is mentioned, external_select repo selector is shown."""
|
||||
# Setup Redis mock
|
||||
mock_redis = AsyncMock()
|
||||
mock_sio.manager.redis = mock_redis
|
||||
@@ -167,75 +162,17 @@ class TestRepoVerificationHandling:
|
||||
mock_send_message.assert_called_once()
|
||||
call_args = mock_send_message.call_args
|
||||
|
||||
# Should be the repo selection form with button + external_select
|
||||
# Should be the repo selection form with external_select
|
||||
message = call_args[0][0]
|
||||
assert isinstance(message, dict)
|
||||
assert message.get('text') == 'Choose a Repository:'
|
||||
|
||||
# Verify it's using external_select
|
||||
blocks = message.get('blocks', [])
|
||||
actions_block = next((b for b in blocks if b.get('type') == 'actions'), None)
|
||||
assert actions_block is not None
|
||||
elements = actions_block.get('elements', [])
|
||||
|
||||
# Should have 2 elements: button and external_select
|
||||
assert len(elements) == 2
|
||||
|
||||
# First element: "No Repository" button (immediately available)
|
||||
assert elements[0].get('type') == 'button'
|
||||
assert elements[0].get('action_id').startswith('no_repository:')
|
||||
assert elements[0].get('value') == '-'
|
||||
|
||||
# Second element: external_select for searching repos
|
||||
assert elements[1].get('type') == 'external_select'
|
||||
assert elements[1].get('action_id').startswith('repository_select:')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.slack.slack_manager.sio')
|
||||
async def test_no_repository_button_click_processes_correctly(
|
||||
self,
|
||||
mock_sio,
|
||||
slack_manager,
|
||||
):
|
||||
"""Test that clicking 'No Repository' button correctly processes the interaction.
|
||||
|
||||
This verifies the button click path through receive_form_interaction, ensuring
|
||||
the no_repository: action_id is correctly parsed and processed.
|
||||
"""
|
||||
# Setup: Mock Redis to return a stored user message
|
||||
mock_redis = AsyncMock()
|
||||
mock_sio.manager.redis = mock_redis
|
||||
stored_msg = json.dumps({'text': 'Hello, help me with code', 'user': 'U123'})
|
||||
mock_redis.get = AsyncMock(return_value=stored_msg)
|
||||
|
||||
# Simulate button click payload (what Slack sends when button is clicked)
|
||||
button_payload = {
|
||||
'type': 'block_actions',
|
||||
'actions': [
|
||||
{
|
||||
'action_id': 'no_repository:1234567890.123456:None',
|
||||
'type': 'button',
|
||||
'value': '-',
|
||||
}
|
||||
],
|
||||
'user': {'id': 'U123'},
|
||||
'container': {'channel_id': 'C123'},
|
||||
'team': {'id': 'T123'},
|
||||
}
|
||||
|
||||
# Mock receive_message to capture what's passed to it
|
||||
with patch.object(
|
||||
slack_manager, 'receive_message', new_callable=AsyncMock
|
||||
) as mock_receive:
|
||||
await slack_manager.receive_form_interaction(button_payload)
|
||||
|
||||
# Verify receive_message was called
|
||||
mock_receive.assert_called_once()
|
||||
|
||||
# Verify the message payload has selected_repo as None
|
||||
call_args = mock_receive.call_args[0][0]
|
||||
assert call_args.message['selected_repo'] is None
|
||||
assert call_args.message['message_ts'] == '1234567890.123456'
|
||||
assert call_args.message['thread_ts'] is None
|
||||
assert len(elements) > 0
|
||||
assert elements[0].get('type') == 'external_select'
|
||||
|
||||
@patch('integrations.slack.slack_manager.sio')
|
||||
@patch('integrations.slack.slack_manager.ProviderHandler')
|
||||
@@ -286,8 +223,8 @@ class TestRepoVerificationHandling:
|
||||
class TestBuildRepoOptions:
|
||||
"""Test the _build_repo_options helper method.
|
||||
|
||||
Note: _build_repo_options returns only actual repositories. The "No Repository"
|
||||
option is now handled by a separate button in the form, not the dropdown.
|
||||
Note: _build_repo_options always includes the "No Repository" option at the top.
|
||||
This is by design for the external_select dropdown.
|
||||
"""
|
||||
|
||||
def test_build_options_with_repos(self, slack_manager):
|
||||
@@ -310,20 +247,21 @@ class TestBuildRepoOptions:
|
||||
|
||||
options = slack_manager._build_repo_options(repos)
|
||||
|
||||
# Should have 2 options (repos only - "No Repository" is now a button)
|
||||
assert len(options) == 2
|
||||
assert options[0]['value'] == 'owner/repo1'
|
||||
assert options[1]['value'] == 'owner/repo2'
|
||||
# Should have 3 options: "No Repository" + 2 repos
|
||||
assert len(options) == 3
|
||||
assert options[0]['value'] == '-'
|
||||
assert options[0]['text']['text'] == 'No Repository'
|
||||
assert options[1]['value'] == 'owner/repo1'
|
||||
assert options[2]['value'] == 'owner/repo2'
|
||||
|
||||
def test_build_options_empty_repos(self, slack_manager):
|
||||
"""Test building options with empty repo list returns empty list.
|
||||
|
||||
Note: "No Repository" is now handled by a separate button in the form.
|
||||
"""
|
||||
"""Test building options with empty repo list still includes No Repository."""
|
||||
options = slack_manager._build_repo_options([])
|
||||
|
||||
# Should have 0 options (empty list)
|
||||
assert len(options) == 0
|
||||
# Should have 1 option: just "No Repository"
|
||||
assert len(options) == 1
|
||||
assert options[0]['value'] == '-'
|
||||
assert options[0]['text']['text'] == 'No Repository'
|
||||
|
||||
def test_build_options_truncates_long_names(self, slack_manager):
|
||||
"""Test that repo names longer than 75 chars are truncated."""
|
||||
@@ -340,12 +278,12 @@ class TestBuildRepoOptions:
|
||||
|
||||
options = slack_manager._build_repo_options(repos)
|
||||
|
||||
# Should have 1 option (the repo only - "No Repository" is a button)
|
||||
assert len(options) == 1
|
||||
# First option is "No Repository", second is the repo
|
||||
assert len(options) == 2
|
||||
# Text should be truncated to 75 chars
|
||||
assert len(options[0]['text']['text']) == 75
|
||||
assert len(options[1]['text']['text']) == 75
|
||||
# But value should have full name
|
||||
assert options[0]['value'] == long_name
|
||||
assert options[1]['value'] == long_name
|
||||
|
||||
|
||||
class TestSearchRepositories:
|
||||
@@ -475,23 +413,23 @@ class TestSearchRepositories:
|
||||
options = slack_manager._build_repo_options(search_results)
|
||||
|
||||
# Verify: Options are correctly built from search results
|
||||
# Note: "No Repository" is now a button, not in the dropdown
|
||||
assert len(options) == 3 # 3 repos only
|
||||
assert len(options) == 4 # "No Repository" + 3 repos
|
||||
|
||||
# Options should be the repos in order
|
||||
assert options[0]['value'] == 'myorg/react-dashboard'
|
||||
assert options[0]['text']['text'] == 'myorg/react-dashboard'
|
||||
assert options[1]['value'] == 'myorg/python-api'
|
||||
assert options[2]['value'] == 'myorg/docs-site'
|
||||
# First option should be "No Repository"
|
||||
assert options[0]['value'] == '-'
|
||||
assert options[0]['text']['text'] == 'No Repository'
|
||||
|
||||
# Remaining options should be the repos in order
|
||||
assert options[1]['value'] == 'myorg/react-dashboard'
|
||||
assert options[1]['text']['text'] == 'myorg/react-dashboard'
|
||||
assert options[2]['value'] == 'myorg/python-api'
|
||||
assert options[3]['value'] == 'myorg/docs-site'
|
||||
|
||||
@patch('integrations.slack.slack_manager.ProviderHandler')
|
||||
async def test_search_with_empty_results_builds_empty_options(
|
||||
async def test_search_with_empty_results_builds_no_repo_only_option(
|
||||
self, mock_provider_handler_class, slack_manager, mock_user_auth
|
||||
):
|
||||
"""Test that when search returns no results, empty options list is returned.
|
||||
|
||||
Note: "No Repository" is now handled by a separate button in the form.
|
||||
"""
|
||||
"""Test that when search returns no results, only 'No Repository' option is shown."""
|
||||
# Setup: No matching repos
|
||||
mock_provider_handler = MagicMock()
|
||||
mock_provider_handler.search_repositories = AsyncMock(return_value=[])
|
||||
@@ -509,8 +447,10 @@ class TestSearchRepositories:
|
||||
)
|
||||
options = slack_manager._build_repo_options(search_results)
|
||||
|
||||
# Verify: Empty options list (button handles "No Repository")
|
||||
assert len(options) == 0
|
||||
# Verify: Only "No Repository" option
|
||||
assert len(options) == 1
|
||||
assert options[0]['value'] == '-'
|
||||
assert options[0]['text']['text'] == 'No Repository'
|
||||
|
||||
|
||||
class TestUserMsgStorage:
|
||||
@@ -729,10 +669,7 @@ class TestOnOptionsLoadEndpoint:
|
||||
async def test_on_options_load_disabled_returns_empty_options(
|
||||
self, mock_request, background_tasks
|
||||
):
|
||||
"""Test that when webhooks are disabled, empty options are returned.
|
||||
|
||||
Note: 'No Repository' is handled by a separate button in the form.
|
||||
"""
|
||||
"""Test that when webhooks are disabled, empty options are returned."""
|
||||
from server.routes.integration.slack import on_options_load
|
||||
|
||||
response = await on_options_load(mock_request, background_tasks)
|
||||
@@ -746,10 +683,7 @@ class TestOnOptionsLoadEndpoint:
|
||||
async def test_on_options_load_no_payload_returns_empty_options(
|
||||
self, mock_request, background_tasks
|
||||
):
|
||||
"""Test that when no payload is in request, empty options are returned.
|
||||
|
||||
Note: 'No Repository' is handled by a separate button in the form.
|
||||
"""
|
||||
"""Test that when no payload is in request, empty options are returned."""
|
||||
from server.routes.integration.slack import on_options_load
|
||||
|
||||
mock_request.body = AsyncMock(return_value=b'')
|
||||
@@ -797,10 +731,7 @@ class TestOnOptionsLoadEndpoint:
|
||||
async def test_on_options_load_wrong_payload_type_returns_empty_options(
|
||||
self, mock_signature_verifier, mock_request, background_tasks
|
||||
):
|
||||
"""Test that non-block_suggestion payload returns empty options.
|
||||
|
||||
Note: 'No Repository' is handled by a separate button in the form.
|
||||
"""
|
||||
"""Test that non-block_suggestion payload returns empty options."""
|
||||
from server.routes.integration.slack import on_options_load
|
||||
|
||||
payload = {
|
||||
@@ -833,10 +764,7 @@ class TestOnOptionsLoadEndpoint:
|
||||
background_tasks,
|
||||
valid_block_suggestion_payload,
|
||||
):
|
||||
"""Test that unauthenticated users get empty options and linking message is queued.
|
||||
|
||||
Note: 'No Repository' is handled by a separate button in the form.
|
||||
"""
|
||||
"""Test that unauthenticated users get empty options and linking message is queued."""
|
||||
from server.routes.integration.slack import on_options_load
|
||||
|
||||
payload_str = json.dumps(valid_block_suggestion_payload)
|
||||
@@ -889,8 +817,9 @@ class TestOnOptionsLoadEndpoint:
|
||||
return_value=(mock_slack_user, mock_user_auth)
|
||||
)
|
||||
|
||||
# Expected options from search_repos_for_slack (no "No Repository" - that's a button)
|
||||
# Expected options from search_repos_for_slack
|
||||
expected_options = [
|
||||
{'text': {'type': 'plain_text', 'text': 'No Repository'}, 'value': '-'},
|
||||
{
|
||||
'text': {'type': 'plain_text', 'text': 'owner/repo1'},
|
||||
'value': 'owner/repo1',
|
||||
@@ -949,8 +878,11 @@ class TestOnOptionsLoadEndpoint:
|
||||
mock_slack_manager.authenticate_user = AsyncMock(
|
||||
return_value=(mock_slack_user, mock_user_auth)
|
||||
)
|
||||
# Empty search returns empty list (no repos found, and "No Repository" is a button)
|
||||
mock_slack_manager.search_repos_for_slack = AsyncMock(return_value=[])
|
||||
mock_slack_manager.search_repos_for_slack = AsyncMock(
|
||||
return_value=[
|
||||
{'text': {'type': 'plain_text', 'text': 'No Repository'}, 'value': '-'}
|
||||
]
|
||||
)
|
||||
|
||||
response = await on_options_load(mock_request, background_tasks)
|
||||
|
||||
@@ -975,10 +907,7 @@ class TestOnOptionsLoadEndpoint:
|
||||
mock_slack_user,
|
||||
mock_user_auth,
|
||||
):
|
||||
"""Test that when search raises an exception, empty options are returned gracefully.
|
||||
|
||||
Note: 'No Repository' is handled by a separate button in the form.
|
||||
"""
|
||||
"""Test that when search raises an exception, empty options are returned gracefully."""
|
||||
from server.routes.integration.slack import on_options_load
|
||||
|
||||
payload_str = json.dumps(valid_block_suggestion_payload)
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
"""Tests for Slack view org routing logic.
|
||||
|
||||
Tests that the SlackNewConversationView correctly resolves the target org
|
||||
based on claimed git organizations and passes it through V0/V1 paths.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from integrations.slack.slack_view import SlackNewConversationView
|
||||
from storage.slack_user import SlackUser
|
||||
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
KEYCLOAK_USER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_slack_user():
|
||||
"""Create a mock SlackUser."""
|
||||
user = SlackUser()
|
||||
user.slack_user_id = 'U1234567890'
|
||||
user.keycloak_user_id = KEYCLOAK_USER_ID
|
||||
user.slack_display_name = 'Test User'
|
||||
user.org_id = UUID('cccccccc-cccc-cccc-cccc-cccccccccccc')
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_auth():
|
||||
"""Create a mock UserAuth."""
|
||||
auth = MagicMock(spec=UserAuth)
|
||||
auth.get_provider_tokens = AsyncMock(
|
||||
return_value={ProviderType.GITHUB: MagicMock()}
|
||||
)
|
||||
auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
|
||||
auth.get_access_token = AsyncMock(return_value='access-token')
|
||||
auth.get_user_id = AsyncMock(return_value=KEYCLOAK_USER_ID)
|
||||
return auth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack_view(mock_slack_user, mock_user_auth):
|
||||
"""Create a SlackNewConversationView instance for testing."""
|
||||
return SlackNewConversationView(
|
||||
bot_access_token='xoxb-test-token',
|
||||
user_msg='Hello OpenHands!',
|
||||
slack_user_id='U1234567890',
|
||||
slack_to_openhands_user=mock_slack_user,
|
||||
saas_user_auth=mock_user_auth,
|
||||
channel_id='C1234567890',
|
||||
message_ts='1234567890.123456',
|
||||
thread_ts=None,
|
||||
selected_repo='OpenHands/foo',
|
||||
should_extract=True,
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id='T1234567890',
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack_view_no_repo(mock_slack_user, mock_user_auth):
|
||||
"""Create a SlackNewConversationView with no selected repo."""
|
||||
return SlackNewConversationView(
|
||||
bot_access_token='xoxb-test-token',
|
||||
user_msg='Hello OpenHands!',
|
||||
slack_user_id='U1234567890',
|
||||
slack_to_openhands_user=mock_slack_user,
|
||||
saas_user_auth=mock_user_auth,
|
||||
channel_id='C1234567890',
|
||||
message_ts='1234567890.123456',
|
||||
thread_ts=None,
|
||||
selected_repo=None,
|
||||
should_extract=True,
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id='T1234567890',
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
class TestSlackV0ConversationRouting:
|
||||
"""Test V0 conversation routing logic in Slack integration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
|
||||
@patch('integrations.slack.slack_view.ProviderHandler')
|
||||
@patch(
|
||||
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
|
||||
async def test_v0_passes_resolver_org_id(
|
||||
self,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_provider_handler_cls,
|
||||
mock_resolve_org,
|
||||
mock_v1_enabled,
|
||||
slack_view,
|
||||
):
|
||||
"""V0 path should pass resolver_org_id to SaasConversationStore.get_resolver_instance."""
|
||||
# Arrange
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.git_provider = ProviderType.GITHUB
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
|
||||
mock_provider_handler_cls.return_value = mock_handler
|
||||
|
||||
mock_resolve_org.return_value = CLAIMING_ORG_ID
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
|
||||
|
||||
mock_jinja = MagicMock()
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch.object(
|
||||
slack_view,
|
||||
'_get_instructions',
|
||||
new_callable=AsyncMock,
|
||||
return_value=('msg', 'instructions'),
|
||||
),
|
||||
patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock),
|
||||
):
|
||||
await slack_view.create_or_update_conversation(mock_jinja)
|
||||
|
||||
# Assert
|
||||
mock_resolve_org.assert_called_once_with(
|
||||
provider='github',
|
||||
full_repo_name='OpenHands/foo',
|
||||
keycloak_user_id=KEYCLOAK_USER_ID,
|
||||
)
|
||||
mock_get_resolver_instance.assert_called_once()
|
||||
call_args = mock_get_resolver_instance.call_args
|
||||
assert call_args[0][1] == KEYCLOAK_USER_ID # user_id
|
||||
assert call_args[0][2] == CLAIMING_ORG_ID # resolver_org_id
|
||||
mock_store.save_metadata.assert_called_once()
|
||||
saved_metadata = mock_store.save_metadata.call_args[0][0]
|
||||
assert saved_metadata.git_provider == ProviderType.GITHUB
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
|
||||
@patch('integrations.slack.slack_view.ProviderHandler')
|
||||
@patch(
|
||||
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
|
||||
async def test_v0_passes_none_when_no_claim(
|
||||
self,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_provider_handler_cls,
|
||||
mock_resolve_org,
|
||||
mock_v1_enabled,
|
||||
slack_view,
|
||||
):
|
||||
"""V0 path should pass resolver_org_id=None when no claim exists."""
|
||||
# Arrange
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.git_provider = ProviderType.GITHUB
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
|
||||
mock_provider_handler_cls.return_value = mock_handler
|
||||
|
||||
mock_resolve_org.return_value = None
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
|
||||
|
||||
mock_jinja = MagicMock()
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch.object(
|
||||
slack_view,
|
||||
'_get_instructions',
|
||||
new_callable=AsyncMock,
|
||||
return_value=('msg', 'instructions'),
|
||||
),
|
||||
patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock),
|
||||
):
|
||||
await slack_view.create_or_update_conversation(mock_jinja)
|
||||
|
||||
# Assert
|
||||
call_args = mock_get_resolver_instance.call_args
|
||||
assert call_args[0][2] is None # resolver_org_id is None
|
||||
|
||||
|
||||
class TestSlackV1ConversationRouting:
|
||||
"""Test V1 conversation routing logic in Slack integration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
|
||||
@patch('integrations.slack.slack_view.ProviderHandler')
|
||||
@patch('integrations.slack.slack_view.get_app_conversation_service')
|
||||
@patch('integrations.slack.slack_view.ResolverUserContext')
|
||||
async def test_v1_passes_resolver_org_id_to_context(
|
||||
self,
|
||||
mock_resolver_ctx_cls,
|
||||
mock_get_service,
|
||||
mock_provider_handler_cls,
|
||||
mock_resolve_org,
|
||||
mock_v1_enabled,
|
||||
slack_view,
|
||||
):
|
||||
"""V1 path should pass resolver_org_id to ResolverUserContext."""
|
||||
# Arrange
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.git_provider = ProviderType.GITHUB
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
|
||||
mock_provider_handler_cls.return_value = mock_handler
|
||||
|
||||
mock_resolve_org.return_value = CLAIMING_ORG_ID
|
||||
mock_resolver_ctx_cls.return_value = MagicMock()
|
||||
|
||||
# Mock the async context manager for app_conversation_service
|
||||
mock_service = MagicMock()
|
||||
mock_service.start_app_conversation = MagicMock(return_value=aiter_empty())
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_service)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_get_service.return_value = mock_ctx
|
||||
|
||||
mock_jinja = MagicMock()
|
||||
|
||||
# Act
|
||||
with patch.object(
|
||||
slack_view,
|
||||
'_get_instructions',
|
||||
new_callable=AsyncMock,
|
||||
return_value=('msg', 'instructions'),
|
||||
):
|
||||
with patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock):
|
||||
await slack_view.create_or_update_conversation(mock_jinja)
|
||||
|
||||
# Assert
|
||||
mock_resolve_org.assert_called_once_with(
|
||||
provider='github',
|
||||
full_repo_name='OpenHands/foo',
|
||||
keycloak_user_id=KEYCLOAK_USER_ID,
|
||||
)
|
||||
mock_resolver_ctx_cls.assert_called_once_with(
|
||||
saas_user_auth=slack_view.saas_user_auth,
|
||||
resolver_org_id=CLAIMING_ORG_ID,
|
||||
)
|
||||
|
||||
|
||||
class TestSlackNoRepoRouting:
|
||||
"""Test routing when no repository is selected."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
|
||||
@patch(
|
||||
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
|
||||
async def test_no_repo_skips_org_resolution(
|
||||
self,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_resolve_org,
|
||||
mock_v1_enabled,
|
||||
slack_view_no_repo,
|
||||
):
|
||||
"""When selected_repo is None, org resolution should be skipped."""
|
||||
# Arrange
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
|
||||
mock_jinja = MagicMock()
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch.object(
|
||||
slack_view_no_repo,
|
||||
'_get_instructions',
|
||||
new_callable=AsyncMock,
|
||||
return_value=('msg', 'instructions'),
|
||||
),
|
||||
patch.object(
|
||||
slack_view_no_repo, 'save_slack_convo', new_callable=AsyncMock
|
||||
),
|
||||
patch.object(slack_view_no_repo, '_verify_necessary_values_are_set'),
|
||||
):
|
||||
await slack_view_no_repo.create_or_update_conversation(mock_jinja)
|
||||
|
||||
# Assert
|
||||
mock_resolve_org.assert_not_called()
|
||||
call_args = mock_get_resolver_instance.call_args
|
||||
assert call_args[0][2] is None # resolver_org_id is None
|
||||
saved_metadata = mock_store.save_metadata.call_args[0][0]
|
||||
assert saved_metadata.git_provider is None
|
||||
|
||||
|
||||
async def aiter_empty():
|
||||
"""Helper: empty async iterator."""
|
||||
return
|
||||
yield # noqa: unreachable - makes this an async generator
|
||||
@@ -1,141 +0,0 @@
|
||||
"""Tests for the GET /api/user/git-organizations endpoint.
|
||||
|
||||
This endpoint returns git organizations for the user's active provider
|
||||
in SaaS mode (single provider at a time).
|
||||
"""
|
||||
|
||||
from types import MappingProxyType
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def github_provider_tokens():
|
||||
return MappingProxyType(
|
||||
{ProviderType.GITHUB: ProviderToken(token=SecretStr('gh-token'))}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gitlab_provider_tokens():
|
||||
return MappingProxyType(
|
||||
{ProviderType.GITLAB: ProviderToken(token=SecretStr('gl-token'))}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bitbucket_provider_tokens():
|
||||
return MappingProxyType(
|
||||
{ProviderType.BITBUCKET: ProviderToken(token=SecretStr('bb-token'))}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def azure_devops_provider_tokens():
|
||||
return MappingProxyType(
|
||||
{ProviderType.AZURE_DEVOPS: ProviderToken(token=SecretStr('az-token'))}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_check_idp():
|
||||
with patch('server.routes.user._check_idp', new_callable=AsyncMock) as mock_fn:
|
||||
yield mock_fn
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_provider_tokens_falls_back_to_idp(mock_check_idp):
|
||||
"""When no provider tokens exist, falls back to IDP check."""
|
||||
from server.routes.user import saas_get_user_git_organizations
|
||||
|
||||
mock_check_idp.return_value = {}
|
||||
|
||||
result = await saas_get_user_git_organizations(
|
||||
provider_tokens=None,
|
||||
access_token=SecretStr('token'),
|
||||
user_id='user-1',
|
||||
)
|
||||
|
||||
assert result == {}
|
||||
mock_check_idp.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_provider_returns_400(azure_devops_provider_tokens):
|
||||
"""Unsupported provider returns a 400 error."""
|
||||
from server.routes.user import saas_get_user_git_organizations
|
||||
|
||||
with patch('server.routes.user.ProviderHandler'):
|
||||
result = await saas_get_user_git_organizations(
|
||||
provider_tokens=azure_devops_provider_tokens,
|
||||
access_token=SecretStr('token'),
|
||||
user_id='user-1',
|
||||
)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'provider_tokens_fixture, mock_method, mock_return, expected_provider',
|
||||
[
|
||||
(
|
||||
'github_provider_tokens',
|
||||
'get_organizations_from_installations',
|
||||
['All-Hands-AI', 'OpenHands'],
|
||||
'github',
|
||||
),
|
||||
(
|
||||
'gitlab_provider_tokens',
|
||||
'get_user_groups',
|
||||
['my-team', 'open-source'],
|
||||
'gitlab',
|
||||
),
|
||||
(
|
||||
'bitbucket_provider_tokens',
|
||||
'get_installations',
|
||||
['my-workspace'],
|
||||
'bitbucket',
|
||||
),
|
||||
],
|
||||
ids=['github', 'gitlab', 'bitbucket'],
|
||||
)
|
||||
async def test_provider_routing_with_real_handler(
|
||||
provider_tokens_fixture,
|
||||
mock_method,
|
||||
mock_return,
|
||||
expected_provider,
|
||||
request,
|
||||
):
|
||||
"""Each provider routes to the correct service method and returns the expected JSON structure.
|
||||
|
||||
Uses a real ProviderHandler so the endpoint's if/elif routing and ProviderHandler's
|
||||
delegation are both exercised. Only the low-level git service call is mocked.
|
||||
"""
|
||||
from server.routes.user import saas_get_user_git_organizations
|
||||
|
||||
provider_tokens = request.getfixturevalue(provider_tokens_fixture)
|
||||
|
||||
with patch(
|
||||
'openhands.integrations.provider.ProviderHandler.get_service'
|
||||
) as mock_get_service:
|
||||
mock_service = mock_get_service.return_value
|
||||
setattr(mock_service, mock_method, AsyncMock(return_value=mock_return))
|
||||
|
||||
result = await saas_get_user_git_organizations(
|
||||
provider_tokens=provider_tokens,
|
||||
access_token=SecretStr('token'),
|
||||
user_id='user-1',
|
||||
)
|
||||
|
||||
assert result == {
|
||||
'provider': expected_provider,
|
||||
'organizations': mock_return,
|
||||
}
|
||||
@@ -17,11 +17,11 @@ describe("LoginCTA", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithRouter = (source?: "login_page" | "device_verify") => {
|
||||
const renderWithRouter = () => {
|
||||
const Stub = createRoutesStub([
|
||||
{
|
||||
path: "/",
|
||||
Component: () => <LoginCTA source={source} />,
|
||||
Component: LoginCTA,
|
||||
},
|
||||
{
|
||||
path: "/information-request",
|
||||
@@ -75,32 +75,4 @@ describe("LoginCTA", () => {
|
||||
"/information-request",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render external enterprise URL in device verify mode", () => {
|
||||
renderWithRouter("device_verify");
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://openhands.dev/enterprise",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should track device_verify location when Learn More is clicked in device verify mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter("device_verify");
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "device_verify",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -434,46 +434,6 @@ describe("ConversationCard", () => {
|
||||
expect(screen.queryByTestId("ellipsis-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the llm model when provided", () => {
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
llmModel="anthropic/claude-sonnet-4-20250514"
|
||||
/>,
|
||||
);
|
||||
|
||||
const model = screen.getByTestId("conversation-card-llm-model");
|
||||
expect(model).toBeInTheDocument();
|
||||
expect(model).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
|
||||
expect(model).toHaveAttribute("title", "anthropic/claude-sonnet-4-20250514");
|
||||
expect(model.querySelector("svg")).toBeInTheDocument();
|
||||
|
||||
// Verify truncation structure: text is wrapped in a span with truncate class
|
||||
const textSpan = model.querySelector("span.truncate");
|
||||
expect(textSpan).toBeInTheDocument();
|
||||
expect(textSpan).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
|
||||
});
|
||||
|
||||
it("should not render the llm model when not provided", () => {
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-card-llm-model"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const statusTable: [ConversationStatus, boolean][] = [
|
||||
["RUNNING", true],
|
||||
["STARTING", true],
|
||||
|
||||
@@ -296,46 +296,6 @@ describe("ConversationName", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the llm model when available", () => {
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
conversation_id: "test-conversation-id",
|
||||
title: "Test Conversation",
|
||||
status: "RUNNING",
|
||||
llm_model: "openai/gpt-4o",
|
||||
} as Conversation,
|
||||
});
|
||||
|
||||
renderConversationNameWithRouter();
|
||||
|
||||
const model = screen.getByTestId("conversation-name-llm-model");
|
||||
expect(model).toBeInTheDocument();
|
||||
expect(model).toHaveTextContent("openai/gpt-4o");
|
||||
expect(model).toHaveAttribute("title", "openai/gpt-4o");
|
||||
expect(model.querySelector("svg")).toBeInTheDocument();
|
||||
|
||||
// Verify truncation structure: text is wrapped in a span with truncate class
|
||||
const textSpan = model.querySelector("span.truncate");
|
||||
expect(textSpan).toBeInTheDocument();
|
||||
expect(textSpan).toHaveTextContent("openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("should not render the llm model when not available", () => {
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
conversation_id: "test-conversation-id",
|
||||
title: "Test Conversation",
|
||||
status: "RUNNING",
|
||||
},
|
||||
});
|
||||
|
||||
renderConversationNameWithRouter();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-name-llm-model"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should focus input when entering edit mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationNameWithRouter();
|
||||
|
||||
-33
@@ -2,7 +2,6 @@ import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
|
||||
const CONVERSATION_ID = "conv-abc123";
|
||||
|
||||
@@ -22,11 +21,6 @@ describe("ConversationTabsContextMenu", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockHasTaskList = false;
|
||||
useConversationStore.setState({
|
||||
selectedTab: "editor",
|
||||
isRightPanelShown: true,
|
||||
hasRightPanelToggled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render nothing when isOpen is false", () => {
|
||||
@@ -75,33 +69,6 @@ describe("ConversationTabsContextMenu", () => {
|
||||
expect(storedState.unpinnedTabs).not.toContain("terminal");
|
||||
});
|
||||
|
||||
it("should close the right panel when unpinning the currently active tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByText("COMMON$CHANGES"));
|
||||
|
||||
const storeState = useConversationStore.getState();
|
||||
expect(storeState.hasRightPanelToggled).toBe(false);
|
||||
|
||||
const storedState = JSON.parse(
|
||||
localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
|
||||
);
|
||||
expect(storedState.rightPanelShown).toBe(false);
|
||||
});
|
||||
|
||||
it("should not close the right panel when unpinning a non-active tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByText("COMMON$TERMINAL"));
|
||||
|
||||
const storeState = useConversationStore.getState();
|
||||
expect(storeState.hasRightPanelToggled).toBe(true);
|
||||
});
|
||||
|
||||
describe("with tasklist", () => {
|
||||
beforeEach(() => {
|
||||
mockHasTaskList = true;
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { FileDiffViewer } from "#/components/features/diff-viewer/file-diff-viewer";
|
||||
|
||||
const MOCK_DIFF = { original: "old content", modified: "new content" };
|
||||
const MOCK_MD_DIFF = {
|
||||
original: "# Old Heading",
|
||||
modified: "# New Heading\n\nSome **bold** text",
|
||||
};
|
||||
|
||||
let mockDiff = MOCK_DIFF;
|
||||
let mockIsSuccess = true;
|
||||
let mockIsLoading = false;
|
||||
|
||||
vi.mock("#/hooks/query/use-unified-git-diff", () => ({
|
||||
useUnifiedGitDiff: () => ({
|
||||
data: mockDiff,
|
||||
isLoading: mockIsLoading,
|
||||
isSuccess: mockIsSuccess,
|
||||
isRefetching: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@monaco-editor/react", () => ({
|
||||
DiffEditor: (props: Record<string, unknown>) => (
|
||||
<div data-testid="file-diff-viewer" data-original={props.original} data-modified={props.modified} />
|
||||
),
|
||||
Editor: (props: Record<string, unknown>) => (
|
||||
<div data-testid="file-single-viewer" data-value={props.value} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("#/components/features/markdown/markdown-renderer", () => ({
|
||||
MarkdownRenderer: ({ content }: { content: string }) => (
|
||||
<div data-testid="markdown-renderer">{content}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const expand = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByTestId("collapse"));
|
||||
};
|
||||
|
||||
describe("FileDiffViewer", () => {
|
||||
beforeEach(() => {
|
||||
mockDiff = MOCK_DIFF;
|
||||
mockIsSuccess = true;
|
||||
mockIsLoading = false;
|
||||
});
|
||||
|
||||
it("starts collapsed with no view mode buttons", () => {
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
expect(screen.queryByTestId("view-mode-old")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("view-mode-diff")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("view-mode-new")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows view mode buttons when expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
|
||||
expect(screen.getByTestId("view-mode-old")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("view-mode-diff")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("view-mode-new")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows diff editor by default when expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
|
||||
expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("file-single-viewer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to single editor on 'new' mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
await user.click(screen.getByTestId("view-mode-new"));
|
||||
|
||||
expect(screen.getByTestId("file-single-viewer")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("file-single-viewer")).toHaveAttribute("data-value", "new content");
|
||||
expect(screen.queryByTestId("file-diff-viewer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to single editor on 'old' mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
await user.click(screen.getByTestId("view-mode-old"));
|
||||
|
||||
expect(screen.getByTestId("file-single-viewer")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("file-single-viewer")).toHaveAttribute("data-value", "old content");
|
||||
});
|
||||
|
||||
it("returns to diff editor when switching back to 'diff' mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
await user.click(screen.getByTestId("view-mode-new"));
|
||||
await user.click(screen.getByTestId("view-mode-diff"));
|
||||
|
||||
expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("file-single-viewer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders markdown preview for .md files in 'new' mode", async () => {
|
||||
mockDiff = MOCK_MD_DIFF;
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="README.md" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
await user.click(screen.getByTestId("view-mode-new"));
|
||||
|
||||
expect(screen.getByTestId("markdown-preview")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(/New Heading/);
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(/bold/);
|
||||
expect(screen.queryByTestId("file-single-viewer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders markdown preview for .md files in 'old' mode", async () => {
|
||||
mockDiff = MOCK_MD_DIFF;
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="README.md" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
await user.click(screen.getByTestId("view-mode-old"));
|
||||
|
||||
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(MOCK_MD_DIFF.original);
|
||||
});
|
||||
|
||||
it("shows diff editor for .md files in 'diff' mode", async () => {
|
||||
mockDiff = MOCK_MD_DIFF;
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="README.md" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
|
||||
expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("markdown-preview")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights the active view mode button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileDiffViewer path="src/index.ts" type="M" />);
|
||||
|
||||
await expand(user);
|
||||
|
||||
expect(screen.getByTestId("view-mode-diff").className).toContain("bg-neutral-600");
|
||||
expect(screen.getByTestId("view-mode-old").className).not.toContain("bg-neutral-600");
|
||||
|
||||
await user.click(screen.getByTestId("view-mode-old"));
|
||||
|
||||
expect(screen.getByTestId("view-mode-old").className).toContain("bg-neutral-600");
|
||||
expect(screen.getByTestId("view-mode-diff").className).not.toContain("bg-neutral-600");
|
||||
});
|
||||
});
|
||||
@@ -3,23 +3,9 @@ import { render, screen } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { NewConversation } from "#/components/features/home/new-conversation/new-conversation";
|
||||
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>(
|
||||
"#/hooks/query/use-settings",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getSettingsQueryFn: vi.fn().mockResolvedValue({ v1_enabled: true }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("#/context/use-selected-organization", () => ({
|
||||
useSelectedOrganizationId: () => ({ organizationId: null }),
|
||||
}));
|
||||
|
||||
// Mock the translation function
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
@@ -64,52 +50,31 @@ const renderNewConversation = () => {
|
||||
|
||||
describe("NewConversation", () => {
|
||||
it("should create an empty conversation and redirect when pressing the launch from scratch button", async () => {
|
||||
const createConversationSpy = vi
|
||||
.spyOn(V1ConversationService, "createConversation")
|
||||
.mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "conv-123",
|
||||
sandbox_id: null,
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
sandbox_id: null,
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
|
||||
renderNewConversation();
|
||||
|
||||
const launchButton = screen.getByTestId("launch-new-conversation-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledOnce();
|
||||
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
// expect to be redirected to /conversations/:conversationId
|
||||
await screen.findByTestId("conversation-screen");
|
||||
});
|
||||
|
||||
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
|
||||
// Mock V1 API to never resolve, keeping the mutation in loading state
|
||||
vi.spyOn(V1ConversationService, "createConversation").mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
);
|
||||
|
||||
renderNewConversation();
|
||||
|
||||
const launchButton = screen.getByTestId("launch-new-conversation-button");
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { RecentConversation } from "#/components/features/home/recent-conversations/recent-conversation";
|
||||
import type { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
CONVERSATION$AGO: "ago",
|
||||
COMMON$NO_REPOSITORY: "No repository",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const baseConversation: Conversation = {
|
||||
conversation_id: "test-id",
|
||||
title: "Test Conversation",
|
||||
status: "RUNNING",
|
||||
last_updated_at: "2021-10-01T12:00:00Z",
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
};
|
||||
|
||||
const renderRecentConversation = (conversation: Conversation) =>
|
||||
renderWithProviders(
|
||||
<BrowserRouter>
|
||||
<RecentConversation conversation={conversation} />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
describe("RecentConversation - llm_model", () => {
|
||||
it("should render the llm model when provided", () => {
|
||||
renderRecentConversation({
|
||||
...baseConversation,
|
||||
llm_model: "anthropic/claude-sonnet-4-20250514",
|
||||
});
|
||||
|
||||
const model = screen.getByTestId("recent-conversation-llm-model");
|
||||
expect(model).toBeInTheDocument();
|
||||
expect(model).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
|
||||
expect(model).toHaveAttribute(
|
||||
"title",
|
||||
"anthropic/claude-sonnet-4-20250514",
|
||||
);
|
||||
expect(model.querySelector("svg")).toBeInTheDocument();
|
||||
|
||||
// Verify truncation structure: text is wrapped in a span with truncate class
|
||||
const textSpan = model.querySelector("span.truncate");
|
||||
expect(textSpan).toBeInTheDocument();
|
||||
expect(textSpan).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
|
||||
});
|
||||
|
||||
it("should not render the llm model when not provided", () => {
|
||||
renderRecentConversation(baseConversation);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("recent-conversation-llm-model"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { createRoutesStub, Outlet } from "react-router";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { GitRepository } from "#/types/git";
|
||||
@@ -314,34 +314,23 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
|
||||
const createConversationSpy = vi
|
||||
.spyOn(V1ConversationService, "createConversation")
|
||||
.mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "mock-conversation-id",
|
||||
sandbox_id: null,
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
sandbox_id: null,
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: "rbren/polaris",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
createConversationSpy.mockResolvedValue({
|
||||
conversation_id: "mock-conversation-id",
|
||||
title: "Test Conversation",
|
||||
selected_repository: "user/repo1",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2023-01-01T00:00:00Z",
|
||||
created_at: "2023-01-01T00:00:00Z",
|
||||
status: "STARTING",
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
"retrieveUserGitRepositories",
|
||||
@@ -401,24 +390,20 @@ describe("RepoConnector", () => {
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledOnce();
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
"rbren/polaris",
|
||||
"github",
|
||||
undefined,
|
||||
undefined,
|
||||
"main",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
|
||||
const createConversationSpy = vi.spyOn(
|
||||
V1ConversationService,
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
createConversationSpy.mockImplementation(() => new Promise(() => { })); // Never resolves to keep loading state
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import UserService from "#/api/user-service/user-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import { TaskCard } from "#/components/features/home/tasks/task-card";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>(
|
||||
"#/hooks/query/use-settings",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getSettingsQueryFn: vi.fn().mockResolvedValue({ v1_enabled: true }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("#/context/use-selected-organization", () => ({
|
||||
useSelectedOrganizationId: () => ({ organizationId: null }),
|
||||
}));
|
||||
|
||||
const MOCK_TASK_1: SuggestedTask = {
|
||||
issue_number: 123,
|
||||
repo: "repo1",
|
||||
@@ -69,43 +56,17 @@ describe("TaskCard", () => {
|
||||
});
|
||||
|
||||
it("should call createConversation when clicking the launch button", async () => {
|
||||
const createConversationSpy = vi
|
||||
.spyOn(V1ConversationService, "createConversation")
|
||||
.mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "conv-123",
|
||||
sandbox_id: null,
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
sandbox_id: null,
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
|
||||
renderTaskCard();
|
||||
|
||||
const launchButton = screen.getByTestId("task-launch-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createConversationSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(createConversationSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("creating suggested task conversation", () => {
|
||||
@@ -121,34 +82,10 @@ describe("TaskCard", () => {
|
||||
});
|
||||
|
||||
it("should call create conversation with suggest task trigger and selected suggested task", async () => {
|
||||
const createConversationSpy = vi
|
||||
.spyOn(V1ConversationService, "createConversation")
|
||||
.mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "conv-123",
|
||||
sandbox_id: null,
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
sandbox_id: null,
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: MOCK_RESPOSITORIES[0].full_name,
|
||||
selected_branch: null,
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
|
||||
renderTaskCard(MOCK_TASK_1);
|
||||
|
||||
@@ -159,8 +96,6 @@ describe("TaskCard", () => {
|
||||
MOCK_RESPOSITORIES[0].full_name,
|
||||
MOCK_RESPOSITORIES[0].git_provider,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
git_provider: "github",
|
||||
issue_number: 123,
|
||||
@@ -171,37 +106,27 @@ describe("TaskCard", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should navigate to the conversation page after creating a conversation", async () => {
|
||||
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "test-conversation-id",
|
||||
sandbox_id: null,
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
sandbox_id: null,
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: "repo1",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
createConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-conversation-id",
|
||||
title: "Test Conversation",
|
||||
selected_repository: "repo1",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2023-01-01T00:00:00Z",
|
||||
created_at: "2023-01-01T00:00:00Z",
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
|
||||
renderTaskCard();
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders, createAxiosError } from "test-utils";
|
||||
import { InvitationAcceptModal } from "#/components/features/invitations/invitation-accept-modal";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import * as toastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
// Mock the organization service
|
||||
vi.mock("#/api/organization-service/organization-service.api", () => ({
|
||||
organizationService: {
|
||||
acceptInvitation: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock toast handlers
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displaySuccessToast: vi.fn(),
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("InvitationAcceptModal", () => {
|
||||
const mockToken = "test-invitation-token-123";
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnSuccess = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the modal with title and description", () => {
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("invitation-accept-modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("ORG$INVITATION_ACCEPT_TITLE"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("ORG$INVITATION_ACCEPT_DESCRIPTION"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render accept and cancel buttons", () => {
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("accept-invitation-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cancel-invitation-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClose when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("cancel-invitation-button"));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call acceptInvitation when accept button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
org_id: "org-123",
|
||||
org_name: "Test Organization",
|
||||
role: "member",
|
||||
};
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockResolvedValueOnce(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(organizationService.acceptInvitation).toHaveBeenCalledWith({
|
||||
token: mockToken,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onSuccess with org_id and show success toast on successful acceptance", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
org_id: "org-123",
|
||||
org_name: "Test Organization",
|
||||
role: "member",
|
||||
};
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockResolvedValueOnce(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSuccess).toHaveBeenCalledWith({
|
||||
orgId: "org-123",
|
||||
orgName: "Test Organization",
|
||||
isPersonal: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(toastHandlers.displaySuccessToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show loading spinner and disable buttons while accepting", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Create a promise that we can control
|
||||
let resolvePromise: (value: unknown) => void;
|
||||
const pendingPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockReturnValueOnce(
|
||||
pendingPromise as Promise<{
|
||||
success: boolean;
|
||||
org_id: string;
|
||||
org_name: string;
|
||||
role: string;
|
||||
}>,
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click accept to trigger loading state
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
// Check loading state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId("accept-invitation-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("cancel-invitation-button")).toBeDisabled();
|
||||
|
||||
// Resolve the promise to clean up
|
||||
resolvePromise!({
|
||||
success: true,
|
||||
org_id: "org-123",
|
||||
org_name: "Test Organization",
|
||||
role: "member",
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should show expired error toast and call onClose when invitation is expired", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
|
||||
createAxiosError(400, "Bad Request", { detail: "invitation_expired" }),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
|
||||
"ORG$INVITATION_EXPIRED",
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledOnce();
|
||||
expect(mockOnSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show invalid error toast and call onClose when invitation is invalid", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
|
||||
createAxiosError(400, "Bad Request", { detail: "invitation_invalid" }),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
|
||||
"ORG$INVITATION_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show already member error toast when user is already a member", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
|
||||
createAxiosError(409, "Conflict", { detail: "already_member" }),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
|
||||
"ORG$ALREADY_MEMBER",
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show email mismatch error toast when email does not match", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
|
||||
createAxiosError(403, "Forbidden", { detail: "email_mismatch" }),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
|
||||
"ORG$INVITATION_EMAIL_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show generic error toast for unknown errors", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(organizationService.acceptInvitation).mockRejectedValueOnce(
|
||||
createAxiosError(500, "Internal Server Error", {
|
||||
detail: "unexpected_error",
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<InvitationAcceptModal
|
||||
token={mockToken}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("accept-invitation-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastHandlers.displayErrorToast).toHaveBeenCalledWith(
|
||||
"ORG$INVITATION_ACCEPT_ERROR",
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user