mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
3 Commits
test/maybe
...
fix/git-ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4f7f07d5d | ||
|
|
a34dc949ce | ||
|
|
80e4fe1226 |
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: upcoming-release
|
||||
description: This skill should be used when the user asks to "generate release notes", "list upcoming release PRs", "summarize upcoming release", "/upcoming-release", or needs to know what changes are part of an upcoming release.
|
||||
---
|
||||
|
||||
# Upcoming Release Summary
|
||||
|
||||
Generate a concise summary of PRs included in the upcoming release.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Two commit SHAs are required:
|
||||
- **First SHA**: The older commit (current release)
|
||||
- **Second SHA**: The newer commit (what's being released)
|
||||
|
||||
If the user does not provide both SHAs, ask for them before proceeding.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Run the script from the repository root with the `--json` flag:
|
||||
```bash
|
||||
.github/scripts/find_prs_between_commits.py <older-sha> <newer-sha> --json
|
||||
```
|
||||
|
||||
2. Filter out PRs that are:
|
||||
- Chores
|
||||
- Dependency updates
|
||||
- Adding logs
|
||||
- Refactors
|
||||
|
||||
3. Categorize the remaining PRs:
|
||||
- **Features** - New functionality
|
||||
- **Bug fixes** - Corrections to existing behavior
|
||||
- **Security/CVE fixes** - Security-related changes
|
||||
- **Other** - Everything else
|
||||
|
||||
4. Format the output with PRs listed under their category, including the PR number and a brief description.
|
||||
@@ -1,123 +0,0 @@
|
||||
---
|
||||
name: update-sdk
|
||||
description: This skill should be used when the user asks to "update SDK", "bump SDK version", "pin SDK to a commit", "test unreleased SDK", "update agent-server image", "bump the version", "prepare a release", "what files change for a release", or needs to know how SDK packages are managed in the OpenHands repository. For detailed reference material, see references/docker-image-locations.md and references/sdk-pinning-examples.md in this skill directory.
|
||||
---
|
||||
|
||||
# Update SDK
|
||||
|
||||
Bump SDK packages (`openhands-sdk`, `openhands-agent-server`, `openhands-tools`), pin them to unreleased commits for testing, and cut an OpenHands release.
|
||||
|
||||
## Quick Summary — How Many Files Change?
|
||||
|
||||
| Activity | Manual edits | Auto-regenerated | Total |
|
||||
|----------|:------------:|:----------------:|:-----:|
|
||||
| **SDK bump** (released PyPI version) | 2 | 3 | **5** |
|
||||
| **SDK pin** (unreleased git commit) | 3 | 3 | **6** |
|
||||
| **Release commit** (version bump) | 3 | 0 | **3** |
|
||||
|
||||
The 3 auto-regenerated files are always: `poetry.lock`, `uv.lock`, `enterprise/poetry.lock`.
|
||||
|
||||
## SDK Package Bump — 2 Files + 3 Lock Files
|
||||
|
||||
Land as a separate PR before the release. Examples: `929dcc3` (SDK 1.11.5), `cd235cc` (SDK 1.11.4).
|
||||
|
||||
| File | What to change |
|
||||
|------|----------------|
|
||||
| `pyproject.toml` | `openhands-sdk`, `openhands-agent-server`, `openhands-tools` in **two** sections: the `dependencies` array (PEP 508) **and** `[tool.poetry.dependencies]` |
|
||||
| `openhands/app_server/sandbox/sandbox_spec_service.py` | `AGENT_SERVER_IMAGE` constant — set to `ghcr.io/openhands/agent-server:<version>-python` |
|
||||
|
||||
Then regenerate lock files:
|
||||
```bash
|
||||
poetry lock && uv lock && cd enterprise && poetry lock && cd ..
|
||||
```
|
||||
|
||||
## Docker Image Locations — All Hardcoded References
|
||||
|
||||
For the complete inventory of every file containing a hardcoded Docker image tag or repository, see `references/docker-image-locations.md`. Key files that must stay in sync during an SDK bump:
|
||||
|
||||
| File | Image reference | Updated during SDK bump? |
|
||||
|------|----------------|:------------------------:|
|
||||
| `openhands/app_server/sandbox/sandbox_spec_service.py` | `AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:<tag>-python'` | ✅ Yes |
|
||||
| `docker-compose.yml` | `AGENT_SERVER_IMAGE_TAG` default | ✅ Should be |
|
||||
| `containers/dev/compose.yml` | `AGENT_SERVER_IMAGE_REPOSITORY` + `_TAG` defaults | ✅ Should be |
|
||||
|
||||
> **CI enforcement:** `.github/workflows/check-version-consistency.yml` validates version consistency and compose file image references on every PR and push to main.
|
||||
|
||||
### ⚠️ Docker Image Tag Gotcha (merge-commit SHA)
|
||||
|
||||
The SDK CI in `software-agent-sdk` repo tags Docker images with the **GitHub Actions merge-commit SHA**, NOT the PR head-commit SHA. When pinning to an SDK PR branch:
|
||||
|
||||
1. Check the SDK PR description for the actual image tag (look for the `AGENT_SERVER_IMAGES` section)
|
||||
2. Or query the CI logs: the "Consolidate Build Information" job prints `"short_sha": "<tag>"`
|
||||
3. The merge-commit SHA differs from the head SHA shown in the PR
|
||||
|
||||
For released SDK versions, images use a version tag (e.g., `1.12.0-python`) — no merge-commit ambiguity.
|
||||
|
||||
## Cutting a Release — 3 Files
|
||||
|
||||
A release commit updates the version string across 3 files. Gold-standard examples: 1.3.0 (`d063c8c`), 1.4.0 (`495f48b`).
|
||||
|
||||
| File | What to change |
|
||||
|------|----------------|
|
||||
| `pyproject.toml` | `version = "X.Y.Z"` under `[tool.poetry]` |
|
||||
| `frontend/package.json` | `"version": "X.Y.Z"` |
|
||||
| `frontend/package-lock.json` | `"version": "X.Y.Z"` in **two** places (root object and `packages[""]`) |
|
||||
|
||||
> **Note:** `openhands/version.py` reads the version from `pyproject.toml` at runtime — no manual edit needed there.
|
||||
|
||||
### Compose Files (2 files)
|
||||
|
||||
Both compose files should use `ghcr.io/openhands/agent-server` with the current SDK version tag.
|
||||
|
||||
| File | What to verify |
|
||||
|------|----------------|
|
||||
| `docker-compose.yml` | `AGENT_SERVER_IMAGE_REPOSITORY` defaults to agent-server, `AGENT_SERVER_IMAGE_TAG` is current |
|
||||
| `containers/dev/compose.yml` | Same — must use agent-server, not runtime |
|
||||
|
||||
### Release Workflow
|
||||
|
||||
#### Step 1: Verify the SDK bump has landed
|
||||
|
||||
```bash
|
||||
grep -n "openhands-sdk\|openhands-agent-server\|openhands-tools" pyproject.toml
|
||||
grep -n "AGENT_SERVER_IMAGE" openhands/app_server/sandbox/sandbox_spec_service.py
|
||||
grep "AGENT_SERVER_IMAGE_TAG" docker-compose.yml containers/dev/compose.yml
|
||||
```
|
||||
|
||||
#### Step 2: Bump version numbers
|
||||
|
||||
```bash
|
||||
# Edit pyproject.toml, frontend/package.json, frontend/package-lock.json
|
||||
git add pyproject.toml frontend/package.json frontend/package-lock.json
|
||||
git commit -m "Release X.Y.Z"
|
||||
git tag X.Y.Z
|
||||
```
|
||||
|
||||
Create a `saas-rel-X.Y.Z` branch from the tagged commit for the SaaS deployment pipeline.
|
||||
|
||||
#### Step 3: CI builds Docker images automatically
|
||||
|
||||
The `ghcr-build.yml` workflow triggers on tag pushes and produces:
|
||||
- `ghcr.io/openhands/openhands:X.Y.Z`, `X.Y`, `X`, `latest`
|
||||
- `ghcr.io/openhands/runtime:X.Y.Z-nikolaik`, `X.Y-nikolaik`
|
||||
|
||||
The tagging logic lives in `containers/build.sh` — when `GITHUB_REF_NAME` matches a semver pattern (`^[0-9]+\.[0-9]+\.[0-9]+$`), it auto-generates major, major.minor, and `latest` tags.
|
||||
|
||||
## Development: Pin SDK to an Unreleased Commit
|
||||
|
||||
For detailed examples of all pinning formats (commit, branch, uv-only), see `references/sdk-pinning-examples.md`.
|
||||
|
||||
### Files to change (3 manual + 3 lock files)
|
||||
|
||||
| File | What to change |
|
||||
|------|----------------|
|
||||
| `pyproject.toml` | Pin all 3 SDK packages in **both** `dependencies` and `[tool.poetry.dependencies]` |
|
||||
| `openhands/app_server/sandbox/sandbox_spec_service.py` | `AGENT_SERVER_IMAGE` — use the merge-commit SHA tag, NOT the head-commit SHA |
|
||||
| `docker-compose.yml` | `AGENT_SERVER_IMAGE_TAG` default (for local development) |
|
||||
| `poetry.lock` | Auto-regenerated via `poetry lock` |
|
||||
| `uv.lock` | Auto-regenerated via `uv lock` |
|
||||
| `enterprise/poetry.lock` | Auto-regenerated via `cd enterprise && poetry lock` |
|
||||
|
||||
### CI guard
|
||||
|
||||
The `check-package-versions.yml` workflow blocks merging to `main` if `[tool.poetry.dependencies]` contains any `rev` fields. This ensures unreleased SDK pins do not accidentally ship in a release.
|
||||
@@ -1,84 +0,0 @@
|
||||
# Docker Image Locations — Complete Inventory
|
||||
|
||||
Every file in the OpenHands repository containing a hardcoded Docker image tag, repository, or version-pinned image reference. Organized by update cadence.
|
||||
|
||||
## Updated During SDK Bump (must change)
|
||||
|
||||
These files contain image tags that **must** be updated whenever the SDK version or pinned commit changes.
|
||||
|
||||
### `openhands/app_server/sandbox/sandbox_spec_service.py`
|
||||
- **Line:** `AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:<tag>-python'`
|
||||
- **Format:** `<sdk-version>-python` for releases (e.g., `1.12.0-python`), `<7-char-commit-hash>-python` for dev pins
|
||||
- **Source of truth** for which agent-server image the app server pulls at runtime
|
||||
- **⚠️ Gotcha:** When pinning to an SDK PR, the image tag is the **merge-commit SHA** from GitHub Actions, not the PR head-commit SHA. Check the SDK PR description or CI logs for the correct tag.
|
||||
|
||||
### `docker-compose.yml`
|
||||
- **Lines:**
|
||||
```yaml
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-<tag>-python}
|
||||
```
|
||||
- Used by `docker compose up` for local development
|
||||
|
||||
### `containers/dev/compose.yml`
|
||||
- **Lines:**
|
||||
```yaml
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-<tag>-python}
|
||||
```
|
||||
- Used by the dev container setup
|
||||
- **Known issue:** On main as of 1.4.0, this file still points to `ghcr.io/openhands/runtime` instead of `agent-server`, and the tag is `1.2-nikolaik` (stale from the V0 era). The `check-version-consistency.yml` CI workflow catches this.
|
||||
|
||||
## Updated During Release Commit (version string only)
|
||||
|
||||
### `pyproject.toml`
|
||||
- **Line:** `version = "X.Y.Z"` under `[tool.poetry]`
|
||||
- The Python version is derived from this at runtime via `openhands/version.py`
|
||||
|
||||
### `frontend/package.json`
|
||||
- **Line:** `"version": "X.Y.Z"`
|
||||
|
||||
### `frontend/package-lock.json`
|
||||
- **Two places:** root `"version": "X.Y.Z"` and `packages[""].version`
|
||||
|
||||
## Dynamic References (auto-derived, no manual update)
|
||||
|
||||
### `openhands/version.py`
|
||||
- Reads version from `pyproject.toml` at runtime → `openhands.__version__`
|
||||
|
||||
### `openhands/resolver/issue_resolver.py`
|
||||
- Builds `ghcr.io/openhands/runtime:{openhands.__version__}-nikolaik` dynamically
|
||||
|
||||
### `openhands/runtime/utils/runtime_build.py`
|
||||
- Base repo URL `ghcr.io/openhands/runtime` is a constant; version comes from elsewhere
|
||||
|
||||
### `.github/scripts/update_pr_description.sh`
|
||||
- Uses `${SHORT_SHA}` variable at CI runtime, not hardcoded
|
||||
|
||||
### `enterprise/Dockerfile`
|
||||
- `ARG BASE="ghcr.io/openhands/openhands"` — base image, version supplied at build time
|
||||
|
||||
## V0 Legacy Files (separate update cadence)
|
||||
|
||||
These reference the V0 runtime image (`ghcr.io/openhands/runtime:X.Y-nikolaik`) for local Docker/Kubernetes paths. They are **not** updated as part of a V1 release but may be updated independently.
|
||||
|
||||
### `Development.md`
|
||||
- `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:X.Y-nikolaik`
|
||||
|
||||
### `openhands/runtime/impl/kubernetes/README.md`
|
||||
- `runtime_container_image = "docker.openhands.dev/openhands/runtime:X.Y-nikolaik"`
|
||||
|
||||
### `enterprise/enterprise_local/README.md`
|
||||
- Uses `ghcr.io/openhands/runtime:main-nikolaik` (points to `main`, not versioned)
|
||||
|
||||
### `third_party/runtime/impl/daytona/README.md`
|
||||
- Uses `${OPENHANDS_VERSION}` variable, not hardcoded
|
||||
|
||||
## Image Registries
|
||||
|
||||
| Registry | Usage |
|
||||
|----------|-------|
|
||||
| `ghcr.io/openhands/agent-server` | V1 agent-server (sandbox) — built by SDK repo CI |
|
||||
| `ghcr.io/openhands/openhands` | Main app image — built by `ghcr-build.yml` |
|
||||
| `ghcr.io/openhands/runtime` | V0 runtime sandbox — built by `ghcr-build.yml` |
|
||||
| `docker.openhands.dev/openhands/*` | Mirror/CDN for the above images |
|
||||
@@ -1,103 +0,0 @@
|
||||
# SDK Pinning Examples
|
||||
|
||||
Examples from real commits showing how to pin SDK packages to unreleased commits, branches, or released versions.
|
||||
|
||||
## Pin to a Specific Commit
|
||||
|
||||
Example from commit `169fb76` (pinning all 3 packages to SDK commit `100e9af`):
|
||||
|
||||
### `dependencies` array (PEP 508 format)
|
||||
|
||||
```toml
|
||||
"openhands-agent-server @ git+https://github.com/OpenHands/software-agent-sdk.git@100e9af#subdirectory=openhands-agent-server",
|
||||
"openhands-sdk @ git+https://github.com/OpenHands/software-agent-sdk.git@100e9af#subdirectory=openhands-sdk",
|
||||
"openhands-tools @ git+https://github.com/OpenHands/software-agent-sdk.git@100e9af#subdirectory=openhands-tools",
|
||||
```
|
||||
|
||||
### `[tool.poetry.dependencies]` (Poetry format)
|
||||
|
||||
```toml
|
||||
openhands-sdk = { git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "100e9af", subdirectory = "openhands-sdk" }
|
||||
openhands-agent-server = { git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "100e9af", subdirectory = "openhands-agent-server" }
|
||||
openhands-tools = { git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "100e9af", subdirectory = "openhands-tools" }
|
||||
```
|
||||
|
||||
### `openhands/app_server/sandbox/sandbox_spec_service.py`
|
||||
|
||||
```python
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:<merge-commit-sha>-python'
|
||||
```
|
||||
|
||||
**⚠️ Important:** The image tag is the **merge-commit SHA** from the SDK CI, not the commit hash used in `pyproject.toml`. Look up the correct tag from the SDK PR description or CI logs.
|
||||
|
||||
## Pin to a Branch
|
||||
|
||||
Example from commit `430ee1c` (pinning to branch `openhands/issue-2228-sdk-settings-schema`):
|
||||
|
||||
### `[tool.poetry.dependencies]`
|
||||
|
||||
```toml
|
||||
openhands-sdk = { git = "https://github.com/OpenHands/software-agent-sdk.git", branch = "openhands/issue-2228-sdk-settings-schema", subdirectory = "openhands-sdk" }
|
||||
openhands-agent-server = { git = "https://github.com/OpenHands/software-agent-sdk.git", branch = "openhands/issue-2228-sdk-settings-schema", subdirectory = "openhands-agent-server" }
|
||||
openhands-tools = { git = "https://github.com/OpenHands/software-agent-sdk.git", branch = "openhands/issue-2228-sdk-settings-schema", subdirectory = "openhands-tools" }
|
||||
```
|
||||
|
||||
## Using `[tool.uv.sources]` Override
|
||||
|
||||
When only `uv` needs the override (keep PyPI versions in the main arrays), add a `[tool.uv.sources]` section. Example from commit `1daca49`:
|
||||
|
||||
```toml
|
||||
[tool.uv.sources]
|
||||
openhands-sdk = { git = "https://github.com/OpenHands/software-agent-sdk.git", subdirectory = "openhands-sdk", rev = "4170cca" }
|
||||
openhands-agent-server = { git = "https://github.com/OpenHands/software-agent-sdk.git", subdirectory = "openhands-agent-server", rev = "4170cca" }
|
||||
openhands-tools = { git = "https://github.com/OpenHands/software-agent-sdk.git", subdirectory = "openhands-tools", rev = "4170cca" }
|
||||
```
|
||||
|
||||
## Released PyPI Version (standard release)
|
||||
|
||||
Example from commit `929dcc3` (SDK 1.11.5):
|
||||
|
||||
### `dependencies` array
|
||||
|
||||
```toml
|
||||
"openhands-agent-server==1.11.5",
|
||||
"openhands-sdk==1.11.5",
|
||||
"openhands-tools==1.11.5",
|
||||
```
|
||||
|
||||
### `[tool.poetry.dependencies]`
|
||||
|
||||
```toml
|
||||
openhands-sdk = "1.11.5"
|
||||
openhands-agent-server = "1.11.5"
|
||||
openhands-tools = "1.11.5"
|
||||
```
|
||||
|
||||
### `openhands/app_server/sandbox/sandbox_spec_service.py`
|
||||
|
||||
For released versions, the image tag uses the version number:
|
||||
|
||||
```python
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.11.5-python'
|
||||
```
|
||||
|
||||
However, **some releases use a commit-hash tag** even for the released version. Check which tag format exists on GHCR. Example from `929dcc3`:
|
||||
|
||||
```python
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:010e847-python'
|
||||
```
|
||||
|
||||
## Regenerate Lock Files
|
||||
|
||||
After any change to `pyproject.toml`, always regenerate:
|
||||
|
||||
```bash
|
||||
poetry lock
|
||||
uv lock
|
||||
cd enterprise && poetry lock && cd ..
|
||||
```
|
||||
|
||||
## CI Guards
|
||||
|
||||
- **`check-package-versions.yml`**: Blocks merge to `main` if `[tool.poetry.dependencies]` contains `rev` fields (prevents shipping unreleased SDK pins)
|
||||
- **`check-version-consistency.yml`**: Validates version strings match across `pyproject.toml`, `package.json`, `package-lock.json`, and verifies compose files use `agent-server` images
|
||||
330
.github/scripts/find_prs_between_commits.py
vendored
330
.github/scripts/find_prs_between_commits.py
vendored
@@ -1,330 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Find all PRs that went in between two commits in the OpenHands/OpenHands repository.
|
||||
Handles cherry-picks and different merge strategies.
|
||||
|
||||
This script is designed to run from within the OpenHands repository under .github/scripts:
|
||||
.github/scripts/find_prs_between_commits.py
|
||||
|
||||
Usage: find_prs_between_commits <older_commit> <newer_commit> [--repo <path>]
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def find_openhands_repo() -> Optional[Path]:
|
||||
"""
|
||||
Find the OpenHands repository.
|
||||
Since this script is designed to live in .github/scripts/, it assumes
|
||||
the repository root is two levels up from the script location.
|
||||
Tries:
|
||||
1. Repository root (../../ from script location)
|
||||
2. Current directory
|
||||
3. Environment variable OPENHANDS_REPO
|
||||
"""
|
||||
# Check repository root (assuming script is in .github/scripts/)
|
||||
script_dir = Path(__file__).parent.absolute()
|
||||
repo_root = (
|
||||
script_dir.parent.parent
|
||||
) # Go up two levels: scripts -> .github -> repo root
|
||||
if (repo_root / '.git').exists():
|
||||
return repo_root
|
||||
|
||||
# Check current directory
|
||||
if (Path.cwd() / '.git').exists():
|
||||
return Path.cwd()
|
||||
|
||||
# Check environment variable
|
||||
if 'OPENHANDS_REPO' in os.environ:
|
||||
repo_path = Path(os.environ['OPENHANDS_REPO'])
|
||||
if (repo_path / '.git').exists():
|
||||
return repo_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def run_git_command(cmd: list[str], repo_path: Path) -> str:
|
||||
"""Run a git command in the repository directory and return its output."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, check=True, cwd=str(repo_path)
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'Error running git command: {" ".join(cmd)}', file=sys.stderr)
|
||||
print(f'Error: {e.stderr}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def extract_pr_numbers_from_message(message: str) -> set[int]:
|
||||
"""Extract PR numbers from commit message in any common format."""
|
||||
# Match #12345 anywhere, including in patterns like (#12345) or "Merge pull request #12345"
|
||||
matches = re.findall(r'#(\d+)', message)
|
||||
return set(int(m) for m in matches)
|
||||
|
||||
|
||||
def get_commit_info(commit_hash: str, repo_path: Path) -> tuple[str, str, str]:
|
||||
"""Get commit subject, body, and author from a commit hash."""
|
||||
subject = run_git_command(
|
||||
['git', 'log', '-1', '--format=%s', commit_hash], repo_path
|
||||
)
|
||||
body = run_git_command(['git', 'log', '-1', '--format=%b', commit_hash], repo_path)
|
||||
author = run_git_command(
|
||||
['git', 'log', '-1', '--format=%an <%ae>', commit_hash], repo_path
|
||||
)
|
||||
return subject, body, author
|
||||
|
||||
|
||||
def get_commits_between(
|
||||
older_commit: str, newer_commit: str, repo_path: Path
|
||||
) -> list[str]:
|
||||
"""Get all commit hashes between two commits."""
|
||||
commits_output = run_git_command(
|
||||
['git', 'rev-list', f'{older_commit}..{newer_commit}'], repo_path
|
||||
)
|
||||
|
||||
if not commits_output:
|
||||
return []
|
||||
|
||||
return commits_output.split('\n')
|
||||
|
||||
|
||||
def get_pr_info_from_github(pr_number: int, repo_path: Path) -> Optional[dict]:
|
||||
"""Get PR information from GitHub API if GITHUB_TOKEN is available."""
|
||||
try:
|
||||
# Set up environment with GitHub token
|
||||
env = os.environ.copy()
|
||||
if 'GITHUB_TOKEN' in env:
|
||||
env['GH_TOKEN'] = env['GITHUB_TOKEN']
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
'gh',
|
||||
'pr',
|
||||
'view',
|
||||
str(pr_number),
|
||||
'--json',
|
||||
'number,title,author,mergedAt,baseRefName,headRefName,url',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
env=env,
|
||||
cwd=str(repo_path),
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def find_prs_between_commits(
|
||||
older_commit: str, newer_commit: str, repo_path: Path
|
||||
) -> dict[int, dict]:
|
||||
"""
|
||||
Find all PRs that went in between two commits.
|
||||
Returns a dictionary mapping PR numbers to their information.
|
||||
"""
|
||||
print(f'Repository: {repo_path}', file=sys.stderr)
|
||||
print('Finding PRs between commits:', file=sys.stderr)
|
||||
print(f' Older: {older_commit}', file=sys.stderr)
|
||||
print(f' Newer: {newer_commit}', file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
|
||||
# Verify commits exist
|
||||
try:
|
||||
run_git_command(['git', 'rev-parse', '--verify', older_commit], repo_path)
|
||||
run_git_command(['git', 'rev-parse', '--verify', newer_commit], repo_path)
|
||||
except SystemExit:
|
||||
print('Error: One or both commits not found in repository', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Extract PRs from the older commit itself (to exclude from results)
|
||||
# These PRs are already included at or before the older commit
|
||||
older_subject, older_body, _ = get_commit_info(older_commit, repo_path)
|
||||
older_message = f'{older_subject}\n{older_body}'
|
||||
excluded_prs = extract_pr_numbers_from_message(older_message)
|
||||
|
||||
if excluded_prs:
|
||||
print(
|
||||
f'Excluding PRs already in older commit: {", ".join(f"#{pr}" for pr in sorted(excluded_prs))}',
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(file=sys.stderr)
|
||||
|
||||
# Get all commits between the two
|
||||
commits = get_commits_between(older_commit, newer_commit, repo_path)
|
||||
print(f'Found {len(commits)} commits to analyze', file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
|
||||
# Extract PR numbers from all commits
|
||||
pr_info: dict[int, dict] = {}
|
||||
commits_by_pr: dict[int, list[str]] = defaultdict(list)
|
||||
|
||||
for commit_hash in commits:
|
||||
subject, body, author = get_commit_info(commit_hash, repo_path)
|
||||
full_message = f'{subject}\n{body}'
|
||||
|
||||
pr_numbers = extract_pr_numbers_from_message(full_message)
|
||||
|
||||
for pr_num in pr_numbers:
|
||||
# Skip PRs that are already in the older commit
|
||||
if pr_num in excluded_prs:
|
||||
continue
|
||||
|
||||
commits_by_pr[pr_num].append(commit_hash)
|
||||
|
||||
if pr_num not in pr_info:
|
||||
pr_info[pr_num] = {
|
||||
'number': pr_num,
|
||||
'first_commit': commit_hash[:8],
|
||||
'first_commit_subject': subject,
|
||||
'commits': [],
|
||||
'github_info': None,
|
||||
}
|
||||
|
||||
pr_info[pr_num]['commits'].append(
|
||||
{'hash': commit_hash[:8], 'subject': subject, 'author': author}
|
||||
)
|
||||
|
||||
# Try to get additional info from GitHub API
|
||||
print('Fetching additional info from GitHub API...', file=sys.stderr)
|
||||
for pr_num in pr_info.keys():
|
||||
github_info = get_pr_info_from_github(pr_num, repo_path)
|
||||
if github_info:
|
||||
pr_info[pr_num]['github_info'] = github_info
|
||||
|
||||
print(file=sys.stderr)
|
||||
|
||||
return pr_info
|
||||
|
||||
|
||||
def print_results(pr_info: dict[int, dict]):
|
||||
"""Print the results in a readable format."""
|
||||
sorted_prs = sorted(pr_info.items(), key=lambda x: x[0])
|
||||
|
||||
print(f'{"=" * 80}')
|
||||
print(f'Found {len(sorted_prs)} PRs')
|
||||
print(f'{"=" * 80}')
|
||||
print()
|
||||
|
||||
for pr_num, info in sorted_prs:
|
||||
print(f'PR #{pr_num}')
|
||||
|
||||
if info['github_info']:
|
||||
gh = info['github_info']
|
||||
print(f' Title: {gh["title"]}')
|
||||
print(f' Author: {gh["author"]["login"]}')
|
||||
print(f' URL: {gh["url"]}')
|
||||
if gh.get('mergedAt'):
|
||||
print(f' Merged: {gh["mergedAt"]}')
|
||||
if gh.get('baseRefName'):
|
||||
print(f' Base: {gh["baseRefName"]} ← {gh["headRefName"]}')
|
||||
else:
|
||||
print(f' Subject: {info["first_commit_subject"]}')
|
||||
|
||||
# Show if this PR has multiple commits (cherry-picked or multiple commits)
|
||||
commit_count = len(info['commits'])
|
||||
if commit_count > 1:
|
||||
print(
|
||||
f' ⚠️ Found {commit_count} commits (possible cherry-pick or multi-commit PR):'
|
||||
)
|
||||
for commit in info['commits'][:3]: # Show first 3
|
||||
print(f' {commit["hash"]}: {commit["subject"][:60]}')
|
||||
if commit_count > 3:
|
||||
print(f' ... and {commit_count - 3} more')
|
||||
else:
|
||||
print(f' Commit: {info["first_commit"]}')
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print('Usage: find_prs_between_commits <older_commit> <newer_commit> [options]')
|
||||
print()
|
||||
print('Arguments:')
|
||||
print(' <older_commit> The older commit hash (or ref)')
|
||||
print(' <newer_commit> The newer commit hash (or ref)')
|
||||
print()
|
||||
print('Options:')
|
||||
print(' --json Output results in JSON format')
|
||||
print(' --repo <path> Path to OpenHands repository (default: auto-detect)')
|
||||
print()
|
||||
print('Example:')
|
||||
print(
|
||||
' find_prs_between_commits c79e0cd3c7a2501a719c9296828d7a31e4030585 35bddb14f15124a3dc448a74651a6592911d99e9'
|
||||
)
|
||||
print()
|
||||
print('Repository Detection:')
|
||||
print(' The script will try to find the OpenHands repository in this order:')
|
||||
print(' 1. --repo argument')
|
||||
print(' 2. Repository root (../../ from script location)')
|
||||
print(' 3. Current directory')
|
||||
print(' 4. OPENHANDS_REPO environment variable')
|
||||
print()
|
||||
print('Environment variables:')
|
||||
print(
|
||||
' GITHUB_TOKEN Optional. If set, will fetch additional PR info from GitHub API'
|
||||
)
|
||||
print(' OPENHANDS_REPO Optional. Path to OpenHands repository')
|
||||
sys.exit(1)
|
||||
|
||||
older_commit = sys.argv[1]
|
||||
newer_commit = sys.argv[2]
|
||||
json_output = '--json' in sys.argv
|
||||
|
||||
# Check for --repo argument
|
||||
repo_path = None
|
||||
if '--repo' in sys.argv:
|
||||
repo_idx = sys.argv.index('--repo')
|
||||
if repo_idx + 1 < len(sys.argv):
|
||||
repo_path = Path(sys.argv[repo_idx + 1])
|
||||
if not (repo_path / '.git').exists():
|
||||
print(f'Error: {repo_path} is not a git repository', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Auto-detect repository if not specified
|
||||
if repo_path is None:
|
||||
repo_path = find_openhands_repo()
|
||||
if repo_path is None:
|
||||
print('Error: Could not find OpenHands repository', file=sys.stderr)
|
||||
print('Please either:', file=sys.stderr)
|
||||
print(
|
||||
' 1. Place this script in .github/scripts/ within the OpenHands repository',
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(' 2. Run from the OpenHands repository directory', file=sys.stderr)
|
||||
print(
|
||||
' 3. Use --repo <path> to specify the repository location',
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(' 4. Set OPENHANDS_REPO environment variable', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find PRs
|
||||
pr_info = find_prs_between_commits(older_commit, newer_commit, repo_path)
|
||||
|
||||
if json_output:
|
||||
# Output as JSON
|
||||
print(json.dumps(pr_info, indent=2))
|
||||
else:
|
||||
# Print results in human-readable format
|
||||
print_results(pr_info)
|
||||
|
||||
# Also print a simple list for easy copying
|
||||
print(f'{"=" * 80}')
|
||||
print('PR Numbers (for easy copying):')
|
||||
print(f'{"=" * 80}')
|
||||
sorted_pr_nums = sorted(pr_info.keys())
|
||||
print(', '.join(f'#{pr}' for pr in sorted_pr_nums))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
122
.github/workflows/check-version-consistency.yml
vendored
122
.github/workflows/check-version-consistency.yml
vendored
@@ -1,122 +0,0 @@
|
||||
name: Check Version Consistency
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-version-consistency:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Check version and Docker image tag consistency
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
# ── 1. Extract the canonical version from pyproject.toml ──────────
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
pyproject = tomllib.load(f)
|
||||
version = pyproject["tool"]["poetry"]["version"]
|
||||
major_minor = ".".join(version.split(".")[:2])
|
||||
print(f"📦 pyproject.toml version: {version} (major.minor: {major_minor})")
|
||||
|
||||
# ── 2. Check frontend/package.json ────────────────────────────────
|
||||
with open("frontend/package.json") as f:
|
||||
pkg = json.load(f)
|
||||
if pkg["version"] != version:
|
||||
errors.append(
|
||||
f"frontend/package.json version is '{pkg['version']}', expected '{version}'"
|
||||
)
|
||||
else:
|
||||
print(f" ✔ frontend/package.json: {pkg['version']}")
|
||||
|
||||
# ── 3. Check frontend/package-lock.json (2 places) ───────────────
|
||||
with open("frontend/package-lock.json") as f:
|
||||
lock = json.load(f)
|
||||
for key, val in [
|
||||
("root.version", lock.get("version")),
|
||||
('packages[""].version', lock.get("packages", {}).get("", {}).get("version")),
|
||||
]:
|
||||
if val != version:
|
||||
errors.append(
|
||||
f"frontend/package-lock.json {key} is '{val}', expected '{version}'"
|
||||
)
|
||||
else:
|
||||
print(f" ✔ frontend/package-lock.json {key}: {val}")
|
||||
|
||||
# ── 4. Check compose files use agent-server images ─────────────────
|
||||
# Both compose files should use ghcr.io/.../agent-server (not runtime).
|
||||
# Agent-server tags use SDK version (e.g. "1.12.0-python") or commit
|
||||
# hashes (e.g. "31536c8-python") — both are acceptable.
|
||||
repo_pattern = re.compile(r"AGENT_SERVER_IMAGE_REPOSITORY[^}]*:-([^}]+)")
|
||||
tag_pattern = re.compile(r"AGENT_SERVER_IMAGE_TAG:-([^}]+)")
|
||||
|
||||
for filepath in ["docker-compose.yml", "containers/dev/compose.yml"]:
|
||||
try:
|
||||
with open(filepath) as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
warnings.append(f"{filepath}: file not found")
|
||||
continue
|
||||
|
||||
repos = repo_pattern.findall(content)
|
||||
tags = tag_pattern.findall(content)
|
||||
|
||||
if not repos:
|
||||
warnings.append(f"{filepath}: no AGENT_SERVER_IMAGE_REPOSITORY default found")
|
||||
else:
|
||||
repo = repos[0]
|
||||
if "agent-server" not in repo:
|
||||
errors.append(
|
||||
f"{filepath}: AGENT_SERVER_IMAGE_REPOSITORY defaults to '{repo}', "
|
||||
f"expected an agent-server image (not runtime)"
|
||||
)
|
||||
else:
|
||||
print(f" ✔ {filepath} image repository: {repo}")
|
||||
|
||||
if not tags:
|
||||
warnings.append(f"{filepath}: no AGENT_SERVER_IMAGE_TAG default found")
|
||||
else:
|
||||
tag = tags[0]
|
||||
if not tag:
|
||||
errors.append(f"{filepath}: AGENT_SERVER_IMAGE_TAG default is empty")
|
||||
else:
|
||||
print(f" ✔ {filepath} image tag: {tag}")
|
||||
|
||||
# ── 5. Report ─────────────────────────────────────────────────────
|
||||
print()
|
||||
if warnings:
|
||||
print("⚠ Warnings:")
|
||||
for w in warnings:
|
||||
print(f" {w}")
|
||||
print()
|
||||
|
||||
if errors:
|
||||
print("❌ FAILED: Version inconsistencies found:\n")
|
||||
for e in errors:
|
||||
print(f" ✖ {e}")
|
||||
print(
|
||||
"\nAll version numbers and Docker image tags must be consistent."
|
||||
"\nSee .agents/skills/update-sdk/SKILL.md for the full checklist."
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✅ All version numbers and Docker image tags are consistent.")
|
||||
PY
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -234,8 +234,6 @@ yarn-error.log*
|
||||
|
||||
logs
|
||||
|
||||
ralph/
|
||||
|
||||
# agent
|
||||
.envrc
|
||||
/workspace
|
||||
|
||||
@@ -165,7 +165,7 @@ Each integration follows a consistent pattern with service classes, storage mode
|
||||
|
||||
**Import Patterns:**
|
||||
- Use relative imports without `enterprise.` prefix in enterprise code
|
||||
- Example: `from storage.database import a_session_maker` not `from enterprise.storage.database import a_session_maker`
|
||||
- Example: `from storage.database import session_maker` not `from enterprise.storage.database import session_maker`
|
||||
- This ensures code works in both OpenHands and enterprise contexts
|
||||
|
||||
**Test Structure:**
|
||||
|
||||
@@ -12,8 +12,8 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- 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.12.0-python}
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/runtime}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.2-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -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.12.0-python}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-31536c8-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:
|
||||
|
||||
@@ -1772,40 +1772,6 @@
|
||||
"sendIdTokenOnLogout": "true",
|
||||
"passMaxAge": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"alias": "bitbucket_data_center",
|
||||
"displayName": "Bitbucket Data Center",
|
||||
"internalId": "b77b4ead-20e8-451c-ad27-99f92d561616",
|
||||
"providerId": "oauth2",
|
||||
"enabled": true,
|
||||
"updateProfileFirstLoginMode": "on",
|
||||
"trustEmail": true,
|
||||
"storeToken": true,
|
||||
"addReadTokenRoleOnCreate": false,
|
||||
"authenticateByDefault": false,
|
||||
"linkOnly": false,
|
||||
"hideOnLogin": false,
|
||||
"config": {
|
||||
"givenNameClaim": "given_name",
|
||||
"userInfoUrl": "https://${WEB_HOST}/bitbucket-dc-proxy/oauth2/userinfo",
|
||||
"clientId": "$BITBUCKET_DATA_CENTER_CLIENT_ID",
|
||||
"tokenUrl": "https://${BITBUCKET_DATA_CENTER_HOST}/rest/oauth2/latest/token",
|
||||
"acceptsPromptNoneForwardFromClient": "false",
|
||||
"fullNameClaim": "name",
|
||||
"userIDClaim": "sub",
|
||||
"emailClaim": "email",
|
||||
"userNameClaim": "preferred_username",
|
||||
"caseSensitiveOriginalUsername": "false",
|
||||
"familyNameClaim": "family_name",
|
||||
"pkceEnabled": "false",
|
||||
"authorizationUrl": "https://${BITBUCKET_DATA_CENTER_HOST}/rest/oauth2/latest/authorize",
|
||||
"clientAuthMethod": "client_secret_post",
|
||||
"syncMode": "IMPORT",
|
||||
"clientSecret": "$BITBUCKET_DATA_CENTER_CLIENT_SECRET",
|
||||
"allowedClockSkew": "0",
|
||||
"defaultScope": "REPO_WRITE"
|
||||
}
|
||||
}
|
||||
],
|
||||
"identityProviderMappers": [
|
||||
@@ -1863,26 +1829,6 @@
|
||||
"syncMode": "FORCE",
|
||||
"attribute": "identity_provider"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id-mapper",
|
||||
"identityProviderAlias": "bitbucket_data_center",
|
||||
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
|
||||
"config": {
|
||||
"syncMode": "FORCE",
|
||||
"claim": "sub",
|
||||
"user.attribute": "bitbucket_data_center_id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "identity-provider",
|
||||
"identityProviderAlias": "bitbucket_data_center",
|
||||
"identityProviderMapper": "hardcoded-attribute-idp-mapper",
|
||||
"config": {
|
||||
"attribute.value": "bitbucket_data_center",
|
||||
"syncMode": "FORCE",
|
||||
"attribute": "identity_provider"
|
||||
}
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
|
||||
@@ -50,10 +50,8 @@ repos:
|
||||
- ./
|
||||
- stripe==11.5.0
|
||||
- pygithub==2.6.1
|
||||
# Use -p (package) to avoid dual module name conflict when using MYPYPATH
|
||||
# MYPYPATH=enterprise allows resolving bare imports like "from integrations.xxx"
|
||||
# Note: tests package excluded to avoid conflict with core openhands tests
|
||||
entry: bash -c 'MYPYPATH=enterprise mypy --config-file enterprise/dev_config/python/mypy.ini -p integrations -p server -p storage -p sync'
|
||||
# To see gaps add `--html-report mypy-report/`
|
||||
entry: mypy --config-file enterprise/dev_config/python/mypy.ini enterprise/
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
files: ^enterprise/
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
warn_unused_configs = True
|
||||
ignore_missing_imports = True
|
||||
check_untyped_defs = True
|
||||
explicit_package_bases = True
|
||||
warn_unreachable = True
|
||||
warn_redundant_casts = True
|
||||
no_implicit_optional = True
|
||||
|
||||
@@ -200,7 +200,7 @@ class MetricsCollector(ABC):
|
||||
"""Base class for metrics collectors."""
|
||||
|
||||
@abstractmethod
|
||||
async def collect(self) -> List[MetricResult]:
|
||||
def collect(self) -> List[MetricResult]:
|
||||
"""Collect metrics and return results."""
|
||||
pass
|
||||
|
||||
@@ -264,13 +264,12 @@ class SystemMetricsCollector(MetricsCollector):
|
||||
def collector_name(self) -> str:
|
||||
return "system_metrics"
|
||||
|
||||
async def collect(self) -> List[MetricResult]:
|
||||
def collect(self) -> List[MetricResult]:
|
||||
results = []
|
||||
|
||||
# Collect user count
|
||||
async with a_session_maker() as session:
|
||||
user_count_result = await session.execute(select(func.count()).select_from(UserSettings))
|
||||
user_count = user_count_result.scalar()
|
||||
with session_maker() as session:
|
||||
user_count = session.query(UserSettings).count()
|
||||
results.append(MetricResult(
|
||||
key="total_users",
|
||||
value=user_count
|
||||
@@ -278,11 +277,9 @@ class SystemMetricsCollector(MetricsCollector):
|
||||
|
||||
# Collect conversation count (last 30 days)
|
||||
thirty_days_ago = datetime.now(timezone.utc) - timedelta(days=30)
|
||||
conversation_count_result = await session.execute(
|
||||
select(func.count()).select_from(StoredConversationMetadata)
|
||||
.where(StoredConversationMetadata.created_at >= thirty_days_ago)
|
||||
)
|
||||
conversation_count = conversation_count_result.scalar()
|
||||
conversation_count = session.query(StoredConversationMetadata)\
|
||||
.filter(StoredConversationMetadata.created_at >= thirty_days_ago)\
|
||||
.count()
|
||||
|
||||
results.append(MetricResult(
|
||||
key="conversations_30d",
|
||||
@@ -306,7 +303,7 @@ class TelemetryCollectionProcessor(MaintenanceTaskProcessor):
|
||||
"""Collect metrics from all registered collectors."""
|
||||
|
||||
# Check if collection is needed
|
||||
if not await self._should_collect():
|
||||
if not self._should_collect():
|
||||
return {"status": "skipped", "reason": "too_recent"}
|
||||
|
||||
# Collect metrics from all registered collectors
|
||||
@@ -316,7 +313,7 @@ class TelemetryCollectionProcessor(MaintenanceTaskProcessor):
|
||||
for collector in collector_registry.get_all_collectors():
|
||||
try:
|
||||
if collector.should_collect():
|
||||
results = await collector.collect()
|
||||
results = collector.collect()
|
||||
for result in results:
|
||||
all_metrics[result.key] = result.value
|
||||
collector_results[collector.collector_name] = len(results)
|
||||
@@ -325,13 +322,13 @@ class TelemetryCollectionProcessor(MaintenanceTaskProcessor):
|
||||
collector_results[collector.collector_name] = f"error: {e}"
|
||||
|
||||
# Store metrics in database
|
||||
async with a_session_maker() as session:
|
||||
with session_maker() as session:
|
||||
telemetry_record = TelemetryMetrics(
|
||||
metrics_data=all_metrics,
|
||||
collected_at=datetime.now(timezone.utc)
|
||||
)
|
||||
session.add(telemetry_record)
|
||||
await session.commit()
|
||||
session.commit()
|
||||
|
||||
# Note: No need to track last_collection_at separately
|
||||
# Can be derived from MAX(collected_at) in telemetry_metrics
|
||||
@@ -342,12 +339,11 @@ class TelemetryCollectionProcessor(MaintenanceTaskProcessor):
|
||||
"collectors_run": collector_results
|
||||
}
|
||||
|
||||
async def _should_collect(self) -> bool:
|
||||
def _should_collect(self) -> bool:
|
||||
"""Check if collection is needed based on interval."""
|
||||
async with a_session_maker() as session:
|
||||
with session_maker() as session:
|
||||
# Get last collection time from metrics table
|
||||
result = await session.execute(select(func.max(TelemetryMetrics.collected_at)))
|
||||
last_collected = result.scalar()
|
||||
last_collected = session.query(func.max(TelemetryMetrics.collected_at)).scalar()
|
||||
if not last_collected:
|
||||
return True
|
||||
|
||||
@@ -370,19 +366,17 @@ class TelemetryUploadProcessor(MaintenanceTaskProcessor):
|
||||
"""Upload pending metrics to Replicated."""
|
||||
|
||||
# Get pending metrics
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(TelemetryMetrics)
|
||||
.where(TelemetryMetrics.uploaded_at.is_(None))
|
||||
.order_by(TelemetryMetrics.collected_at)
|
||||
)
|
||||
pending_metrics = result.scalars().all()
|
||||
with session_maker() as session:
|
||||
pending_metrics = session.query(TelemetryMetrics)\
|
||||
.filter(TelemetryMetrics.uploaded_at.is_(None))\
|
||||
.order_by(TelemetryMetrics.collected_at)\
|
||||
.all()
|
||||
|
||||
if not pending_metrics:
|
||||
return {"status": "no_pending_metrics"}
|
||||
|
||||
# Get admin email - skip if not available
|
||||
admin_email = await self._get_admin_email()
|
||||
admin_email = self._get_admin_email()
|
||||
if not admin_email:
|
||||
logger.info("Skipping telemetry upload - no admin email available")
|
||||
return {
|
||||
@@ -419,15 +413,13 @@ class TelemetryUploadProcessor(MaintenanceTaskProcessor):
|
||||
await instance.set_status(InstanceStatus.RUNNING)
|
||||
|
||||
# Mark as uploaded
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(TelemetryMetrics)
|
||||
.where(TelemetryMetrics.id == metric_record.id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
with session_maker() as session:
|
||||
record = session.query(TelemetryMetrics)\
|
||||
.filter(TelemetryMetrics.id == metric_record.id)\
|
||||
.first()
|
||||
if record:
|
||||
record.uploaded_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
session.commit()
|
||||
|
||||
uploaded_count += 1
|
||||
|
||||
@@ -435,16 +427,14 @@ class TelemetryUploadProcessor(MaintenanceTaskProcessor):
|
||||
logger.error(f"Failed to upload metrics {metric_record.id}: {e}")
|
||||
|
||||
# Update error info
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(TelemetryMetrics)
|
||||
.where(TelemetryMetrics.id == metric_record.id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
with session_maker() as session:
|
||||
record = session.query(TelemetryMetrics)\
|
||||
.filter(TelemetryMetrics.id == metric_record.id)\
|
||||
.first()
|
||||
if record:
|
||||
record.upload_attempts += 1
|
||||
record.last_upload_error = str(e)
|
||||
await session.commit()
|
||||
session.commit()
|
||||
|
||||
failed_count += 1
|
||||
|
||||
@@ -458,7 +448,7 @@ class TelemetryUploadProcessor(MaintenanceTaskProcessor):
|
||||
"total_processed": len(pending_metrics)
|
||||
}
|
||||
|
||||
async def _get_admin_email(self) -> str | None:
|
||||
def _get_admin_email(self) -> str | None:
|
||||
"""Get administrator email for customer identification."""
|
||||
# 1. Check environment variable first
|
||||
env_admin_email = os.getenv('OPENHANDS_ADMIN_EMAIL')
|
||||
@@ -467,15 +457,12 @@ class TelemetryUploadProcessor(MaintenanceTaskProcessor):
|
||||
return env_admin_email
|
||||
|
||||
# 2. Use first active user's email (earliest accepted_tos)
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(UserSettings)
|
||||
.where(UserSettings.email.isnot(None))
|
||||
.where(UserSettings.accepted_tos.isnot(None))
|
||||
.order_by(UserSettings.accepted_tos.asc())
|
||||
.limit(1)
|
||||
)
|
||||
first_user = result.scalar_one_or_none()
|
||||
with session_maker() as session:
|
||||
first_user = session.query(UserSettings)\
|
||||
.filter(UserSettings.email.isnot(None))\
|
||||
.filter(UserSettings.accepted_tos.isnot(None))\
|
||||
.order_by(UserSettings.accepted_tos.asc())\
|
||||
.first()
|
||||
|
||||
if first_user and first_user.email:
|
||||
logger.info(f"Using first active user email: {first_user.email}")
|
||||
@@ -487,16 +474,15 @@ class TelemetryUploadProcessor(MaintenanceTaskProcessor):
|
||||
|
||||
async def _update_telemetry_identity(self, customer_id: str, instance_id: str) -> None:
|
||||
"""Update or create telemetry identity record."""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(select(TelemetryIdentity).limit(1))
|
||||
identity = result.scalar_one_or_none()
|
||||
with session_maker() as session:
|
||||
identity = session.query(TelemetryIdentity).first()
|
||||
if not identity:
|
||||
identity = TelemetryIdentity()
|
||||
session.add(identity)
|
||||
|
||||
identity.customer_id = customer_id
|
||||
identity.instance_id = instance_id
|
||||
await session.commit()
|
||||
session.commit()
|
||||
```
|
||||
|
||||
### 4.4 License Warning System
|
||||
@@ -517,13 +503,11 @@ async def get_license_status():
|
||||
if not _is_openhands_enterprise():
|
||||
return {"warn": False, "message": ""}
|
||||
|
||||
async with a_session_maker() as session:
|
||||
with session_maker() as session:
|
||||
# Get last successful upload time from metrics table
|
||||
result = await session.execute(
|
||||
select(func.max(TelemetryMetrics.uploaded_at))
|
||||
.where(TelemetryMetrics.uploaded_at.isnot(None))
|
||||
)
|
||||
last_upload = result.scalar()
|
||||
last_upload = session.query(func.max(TelemetryMetrics.uploaded_at))\
|
||||
.filter(TelemetryMetrics.uploaded_at.isnot(None))\
|
||||
.scalar()
|
||||
|
||||
if not last_upload:
|
||||
# No successful uploads yet - show warning after 4 days
|
||||
@@ -537,13 +521,10 @@ async def get_license_status():
|
||||
|
||||
if days_since_upload > 4:
|
||||
# Find oldest unsent batch
|
||||
result = await session.execute(
|
||||
select(TelemetryMetrics)
|
||||
.where(TelemetryMetrics.uploaded_at.is_(None))
|
||||
.order_by(TelemetryMetrics.collected_at)
|
||||
.limit(1)
|
||||
)
|
||||
oldest_unsent = result.scalar_one_or_none()
|
||||
oldest_unsent = session.query(TelemetryMetrics)\
|
||||
.filter(TelemetryMetrics.uploaded_at.is_(None))\
|
||||
.order_by(TelemetryMetrics.collected_at)\
|
||||
.first()
|
||||
|
||||
if oldest_unsent:
|
||||
# Calculate expiration date (oldest unsent + 34 days)
|
||||
@@ -649,23 +630,19 @@ spec:
|
||||
- python
|
||||
- -c
|
||||
- |
|
||||
import asyncio
|
||||
from enterprise.storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
|
||||
from enterprise.storage.database import a_session_maker
|
||||
from enterprise.storage.database import session_maker
|
||||
from enterprise.server.telemetry.collection_processor import TelemetryCollectionProcessor
|
||||
|
||||
async def main():
|
||||
# Create collection task
|
||||
processor = TelemetryCollectionProcessor()
|
||||
task = MaintenanceTask()
|
||||
task.set_processor(processor)
|
||||
task.status = MaintenanceTaskStatus.PENDING
|
||||
# Create collection task
|
||||
processor = TelemetryCollectionProcessor()
|
||||
task = MaintenanceTask()
|
||||
task.set_processor(processor)
|
||||
task.status = MaintenanceTaskStatus.PENDING
|
||||
|
||||
async with a_session_maker() as session:
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
|
||||
asyncio.run(main())
|
||||
with session_maker() as session:
|
||||
session.add(task)
|
||||
session.commit()
|
||||
restartPolicy: OnFailure
|
||||
```
|
||||
|
||||
@@ -703,27 +680,23 @@ spec:
|
||||
- python
|
||||
- -c
|
||||
- |
|
||||
import asyncio
|
||||
from enterprise.storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
|
||||
from enterprise.storage.database import a_session_maker
|
||||
from enterprise.storage.database import session_maker
|
||||
from enterprise.server.telemetry.upload_processor import TelemetryUploadProcessor
|
||||
import os
|
||||
|
||||
async def main():
|
||||
# Create upload task
|
||||
processor = TelemetryUploadProcessor(
|
||||
replicated_publishable_key=os.getenv('REPLICATED_PUBLISHABLE_KEY'),
|
||||
replicated_app_slug=os.getenv('REPLICATED_APP_SLUG', 'openhands-enterprise')
|
||||
)
|
||||
task = MaintenanceTask()
|
||||
task.set_processor(processor)
|
||||
task.status = MaintenanceTaskStatus.PENDING
|
||||
# Create upload task
|
||||
processor = TelemetryUploadProcessor(
|
||||
replicated_publishable_key=os.getenv('REPLICATED_PUBLISHABLE_KEY'),
|
||||
replicated_app_slug=os.getenv('REPLICATED_APP_SLUG', 'openhands-enterprise')
|
||||
)
|
||||
task = MaintenanceTask()
|
||||
task.set_processor(processor)
|
||||
task.status = MaintenanceTaskStatus.PENDING
|
||||
|
||||
async with a_session_maker() as session:
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
|
||||
asyncio.run(main())
|
||||
with session_maker() as session:
|
||||
session.add(task)
|
||||
session.commit()
|
||||
restartPolicy: OnFailure
|
||||
```
|
||||
|
||||
|
||||
207
enterprise/downgrade_migrated_users.py
Normal file
207
enterprise/downgrade_migrated_users.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
This script can be removed once orgs is established - probably after Feb 15 2026
|
||||
|
||||
Downgrade script for migrated users.
|
||||
|
||||
This script identifies users who have been migrated (already_migrated=True)
|
||||
and reverts them back to the pre-migration state.
|
||||
|
||||
Usage:
|
||||
# Dry run - just list the users that would be downgraded
|
||||
python downgrade_migrated_users.py --dry-run
|
||||
|
||||
# Downgrade a specific user by their keycloak_user_id
|
||||
python downgrade_migrated_users.py --user-id <user_id>
|
||||
|
||||
# Downgrade all migrated users (with confirmation)
|
||||
python downgrade_migrated_users.py --all
|
||||
|
||||
# Downgrade all migrated users without confirmation (dangerous!)
|
||||
python downgrade_migrated_users.py --all --no-confirm
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
# Add the enterprise directory to the path
|
||||
sys.path.insert(0, '/workspace/project/OpenHands/enterprise')
|
||||
|
||||
from server.logger import logger
|
||||
from sqlalchemy import select, text
|
||||
from storage.database import session_maker
|
||||
from storage.user_settings import UserSettings
|
||||
from storage.user_store import UserStore
|
||||
|
||||
|
||||
def get_migrated_users() -> list[str]:
|
||||
"""Get list of keycloak_user_ids for users who have been migrated.
|
||||
|
||||
This includes:
|
||||
1. Users with already_migrated=True in user_settings (migrated users)
|
||||
2. Users in the 'user' table who don't have a user_settings entry (new sign-ups)
|
||||
"""
|
||||
with session_maker() as session:
|
||||
# Get users from user_settings with already_migrated=True
|
||||
migrated_result = session.execute(
|
||||
select(UserSettings.keycloak_user_id).where(
|
||||
UserSettings.already_migrated.is_(True)
|
||||
)
|
||||
)
|
||||
migrated_users = {row[0] for row in migrated_result.fetchall() if row[0]}
|
||||
|
||||
# Get users from the 'user' table (new sign-ups won't have user_settings)
|
||||
# These are users who signed up after the migration was deployed
|
||||
new_signup_result = session.execute(
|
||||
text("""
|
||||
SELECT CAST(u.id AS VARCHAR)
|
||||
FROM "user" u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM user_settings us
|
||||
WHERE us.keycloak_user_id = CAST(u.id AS VARCHAR)
|
||||
)
|
||||
""")
|
||||
)
|
||||
new_signups = {row[0] for row in new_signup_result.fetchall() if row[0]}
|
||||
|
||||
# Combine both sets
|
||||
all_users = migrated_users | new_signups
|
||||
return list(all_users)
|
||||
|
||||
|
||||
async def downgrade_user(user_id: str) -> bool:
|
||||
"""Downgrade a single user.
|
||||
|
||||
Args:
|
||||
user_id: The keycloak_user_id to downgrade
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
result = await UserStore.downgrade_user(user_id)
|
||||
if result:
|
||||
print(f'✓ Successfully downgraded user: {user_id}')
|
||||
return True
|
||||
else:
|
||||
print(f'✗ Failed to downgrade user: {user_id}')
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f'✗ Error downgrading user {user_id}: {e}')
|
||||
logger.exception(
|
||||
'downgrade_script:error',
|
||||
extra={'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Downgrade migrated users back to pre-migration state'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Just list users that would be downgraded, without making changes',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--user-id',
|
||||
type=str,
|
||||
help='Downgrade a specific user by keycloak_user_id',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--all',
|
||||
action='store_true',
|
||||
help='Downgrade all migrated users',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-confirm',
|
||||
action='store_true',
|
||||
help='Skip confirmation prompt (use with caution!)',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Get list of migrated users
|
||||
migrated_users = get_migrated_users()
|
||||
print(f'\nFound {len(migrated_users)} migrated user(s).')
|
||||
|
||||
if args.dry_run:
|
||||
print('\n--- DRY RUN MODE ---')
|
||||
print('The following users would be downgraded:')
|
||||
for user_id in migrated_users:
|
||||
print(f' - {user_id}')
|
||||
print('\nNo changes were made.')
|
||||
return
|
||||
|
||||
if args.user_id:
|
||||
# Downgrade a specific user
|
||||
if args.user_id not in migrated_users:
|
||||
print(f'\nUser {args.user_id} is not in the migrated users list.')
|
||||
print('Either the user was not migrated, or the user_id is incorrect.')
|
||||
return
|
||||
|
||||
print(f'\nDowngrading user: {args.user_id}')
|
||||
if not args.no_confirm:
|
||||
confirm = input('Are you sure? (yes/no): ')
|
||||
if confirm.lower() != 'yes':
|
||||
print('Cancelled.')
|
||||
return
|
||||
|
||||
success = await downgrade_user(args.user_id)
|
||||
if success:
|
||||
print('\nDowngrade completed successfully.')
|
||||
else:
|
||||
print('\nDowngrade failed. Check logs for details.')
|
||||
sys.exit(1)
|
||||
|
||||
elif args.all:
|
||||
# Downgrade all migrated users
|
||||
if not migrated_users:
|
||||
print('\nNo migrated users to downgrade.')
|
||||
return
|
||||
|
||||
print(f'\n⚠️ About to downgrade {len(migrated_users)} user(s).')
|
||||
if not args.no_confirm:
|
||||
print('\nThis will:')
|
||||
print(' - Revert LiteLLM team/user budget settings')
|
||||
print(' - Delete organization entries')
|
||||
print(' - Delete user entries in the new schema')
|
||||
print(' - Reset the already_migrated flag')
|
||||
print('\nUsers to downgrade:')
|
||||
for user_id in migrated_users[:10]: # Show first 10
|
||||
print(f' - {user_id}')
|
||||
if len(migrated_users) > 10:
|
||||
print(f' ... and {len(migrated_users) - 10} more')
|
||||
|
||||
confirm = input('\nType "yes" to proceed: ')
|
||||
if confirm.lower() != 'yes':
|
||||
print('Cancelled.')
|
||||
return
|
||||
|
||||
print('\nStarting downgrade...\n')
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for user_id in migrated_users:
|
||||
success = await downgrade_user(user_id)
|
||||
if success:
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
print('\n--- Summary ---')
|
||||
print(f'Successful: {success_count}')
|
||||
print(f'Failed: {fail_count}')
|
||||
|
||||
if fail_count > 0:
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
print('\nPlease specify --dry-run, --user-id, or --all')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -109,9 +109,6 @@ lines.append(
|
||||
lines.append(
|
||||
'OPENHANDS_BITBUCKET_SERVICE_CLS=integrations.bitbucket.bitbucket_service.SaaSBitBucketService'
|
||||
)
|
||||
lines.append(
|
||||
'OPENHANDS_BITBUCKET_DATA_CENTER_SERVICE_CLS=integrations.bitbucket_data_center.bitbucket_dc_service.SaaSBitbucketDCService'
|
||||
)
|
||||
lines.append(
|
||||
'OPENHANDS_CONVERSATION_VALIDATOR_CLS=storage.saas_conversation_validator.SaasConversationValidator'
|
||||
)
|
||||
|
||||
47
enterprise/experiments/constants.py
Normal file
47
enterprise/experiments/constants.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
|
||||
import posthog
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
# Initialize PostHog
|
||||
posthog.api_key = os.environ.get('POSTHOG_CLIENT_KEY', 'phc_placeholder')
|
||||
posthog.host = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com')
|
||||
|
||||
# Log PostHog configuration with masked API key for security
|
||||
api_key = posthog.api_key
|
||||
if api_key and len(api_key) > 8:
|
||||
masked_key = f'{api_key[:4]}...{api_key[-4:]}'
|
||||
else:
|
||||
masked_key = 'not_set_or_too_short'
|
||||
logger.info('posthog_configuration', extra={'posthog_api_key_masked': masked_key})
|
||||
|
||||
# Global toggle for the experiment manager
|
||||
ENABLE_EXPERIMENT_MANAGER = (
|
||||
os.environ.get('ENABLE_EXPERIMENT_MANAGER', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
# Get the current experiment type from environment variable
|
||||
# If None, no experiment is running
|
||||
EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT = os.environ.get(
|
||||
'EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT', ''
|
||||
)
|
||||
# System prompt experiment toggle
|
||||
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT = os.environ.get(
|
||||
'EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT', ''
|
||||
)
|
||||
|
||||
EXPERIMENT_CLAUDE4_VS_GPT5 = os.environ.get('EXPERIMENT_CLAUDE4_VS_GPT5', '')
|
||||
|
||||
EXPERIMENT_CONDENSER_MAX_STEP = os.environ.get('EXPERIMENT_CONDENSER_MAX_STEP', '')
|
||||
|
||||
logger.info(
|
||||
'experiment_manager:run_conversation_variant_test:experiment_config',
|
||||
extra={
|
||||
'enable_experiment_manager': ENABLE_EXPERIMENT_MANAGER,
|
||||
'experiment_litellm_default_model_experiment': EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT,
|
||||
'experiment_system_prompt_experiment': EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
|
||||
'experiment_claude4_vs_gpt5_experiment': EXPERIMENT_CLAUDE4_VS_GPT5,
|
||||
'experiment_condenser_max_step': EXPERIMENT_CONDENSER_MAX_STEP,
|
||||
},
|
||||
)
|
||||
99
enterprise/experiments/experiment_manager.py
Normal file
99
enterprise/experiments/experiment_manager.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from uuid import UUID
|
||||
|
||||
from experiments.constants import (
|
||||
ENABLE_EXPERIMENT_MANAGER,
|
||||
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
|
||||
)
|
||||
from experiments.experiment_versions import (
|
||||
handle_system_prompt_experiment,
|
||||
)
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.experiments.experiment_manager import ExperimentManager
|
||||
from openhands.sdk import Agent
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
class SaaSExperimentManager(ExperimentManager):
|
||||
@staticmethod
|
||||
def run_agent_variant_tests__v1(
|
||||
user_id: str | None, conversation_id: UUID, agent: Agent
|
||||
) -> Agent:
|
||||
if not ENABLE_EXPERIMENT_MANAGER:
|
||||
logger.info(
|
||||
'experiment_manager:run_conversation_variant_test:skipped',
|
||||
extra={'reason': 'experiment_manager_disabled'},
|
||||
)
|
||||
return agent
|
||||
|
||||
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
|
||||
# Skip experiment for planning agents which require their specialized prompt
|
||||
if agent.system_prompt_filename != 'system_prompt_planning.j2':
|
||||
agent = agent.model_copy(
|
||||
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
|
||||
)
|
||||
|
||||
return agent
|
||||
|
||||
@staticmethod
|
||||
def run_conversation_variant_test(
|
||||
user_id, conversation_id, conversation_settings
|
||||
) -> ConversationInitData:
|
||||
"""
|
||||
Run conversation variant test and potentially modify the conversation settings
|
||||
based on the PostHog feature flags.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
conversation_id: The conversation ID
|
||||
conversation_settings: The conversation settings that may include convo_id and llm_model
|
||||
|
||||
Returns:
|
||||
The modified conversation settings
|
||||
"""
|
||||
logger.debug(
|
||||
'experiment_manager:run_conversation_variant_test:started',
|
||||
extra={'user_id': user_id, 'conversation_id': conversation_id},
|
||||
)
|
||||
|
||||
return conversation_settings
|
||||
|
||||
@staticmethod
|
||||
def run_config_variant_test(
|
||||
user_id: str | None, conversation_id: str, config: OpenHandsConfig
|
||||
) -> OpenHandsConfig:
|
||||
"""
|
||||
Run agent config variant test and potentially modify the OpenHands config
|
||||
based on the current experiment type and PostHog feature flags.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
conversation_id: The conversation ID
|
||||
config: The OpenHands configuration
|
||||
|
||||
Returns:
|
||||
The modified OpenHands configuration
|
||||
"""
|
||||
logger.info(
|
||||
'experiment_manager:run_config_variant_test:started',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
# Skip all experiment processing if the experiment manager is disabled
|
||||
if not ENABLE_EXPERIMENT_MANAGER:
|
||||
logger.info(
|
||||
'experiment_manager:run_config_variant_test:skipped',
|
||||
extra={'reason': 'experiment_manager_disabled'},
|
||||
)
|
||||
return config
|
||||
|
||||
# Pass the entire OpenHands config to the system prompt experiment
|
||||
# Let the experiment handler directly modify the config as needed
|
||||
modified_config = handle_system_prompt_experiment(
|
||||
user_id, conversation_id, config
|
||||
)
|
||||
|
||||
# Condenser max step experiment is applied via conversation variant test,
|
||||
# not config variant test. Return modified config from system prompt only.
|
||||
return modified_config
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
LiteLLM model experiment handler.
|
||||
|
||||
This module contains the handler for the LiteLLM model experiment.
|
||||
"""
|
||||
|
||||
import posthog
|
||||
from experiments.constants import EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT
|
||||
from server.constants import (
|
||||
IS_FEATURE_ENV,
|
||||
build_litellm_proxy_model_path,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def handle_litellm_default_model_experiment(
|
||||
user_id, conversation_id, conversation_settings
|
||||
):
|
||||
"""
|
||||
Handle the LiteLLM model experiment.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
conversation_id: The conversation ID
|
||||
conversation_settings: The conversation settings
|
||||
|
||||
Returns:
|
||||
Modified conversation settings
|
||||
"""
|
||||
# No-op if the specific experiment is not enabled
|
||||
if not EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT:
|
||||
logger.info(
|
||||
'experiment_manager:ab_testing:skipped',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'reason': 'experiment_not_enabled',
|
||||
'experiment': EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT,
|
||||
},
|
||||
)
|
||||
return conversation_settings
|
||||
|
||||
# Use experiment name as the flag key
|
||||
try:
|
||||
enabled_variant = posthog.get_feature_flag(
|
||||
EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT, conversation_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'experiment_manager:get_feature_flag:failed',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'experiment': EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
return conversation_settings
|
||||
|
||||
# Log the experiment event
|
||||
# If this is a feature environment, add "FEATURE_" prefix to user_id for PostHog
|
||||
posthog_user_id = f'FEATURE_{user_id}' if IS_FEATURE_ENV else user_id
|
||||
|
||||
try:
|
||||
posthog.capture(
|
||||
distinct_id=posthog_user_id,
|
||||
event='model_set',
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
'original_user_id': user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'experiment_manager:posthog_capture:failed',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'experiment': EXPERIMENT_LITELLM_DEFAULT_MODEL_EXPERIMENT,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
# Continue execution as this is not critical
|
||||
|
||||
logger.info(
|
||||
'posthog_capture',
|
||||
extra={
|
||||
'event': 'model_set',
|
||||
'posthog_user_id': posthog_user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
'conversation_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
},
|
||||
)
|
||||
|
||||
# Set the model based on the feature flag variant
|
||||
if enabled_variant == 'claude37':
|
||||
# Use the shared utility to construct the LiteLLM proxy model path
|
||||
model = build_litellm_proxy_model_path('claude-3-7-sonnet-20250219')
|
||||
# Update the conversation settings with the selected model
|
||||
conversation_settings.llm_model = model
|
||||
else:
|
||||
# Update the conversation settings with the default model for the current version
|
||||
conversation_settings.llm_model = get_default_litellm_model()
|
||||
|
||||
return conversation_settings
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
System prompt experiment handler.
|
||||
|
||||
This module contains the handler for the system prompt experiment that uses
|
||||
the PostHog variant as the system prompt filename.
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
import posthog
|
||||
from experiments.constants import EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT
|
||||
from server.constants import IS_FEATURE_ENV
|
||||
from storage.experiment_assignment_store import ExperimentAssignmentStore
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def _get_system_prompt_variant(user_id, conversation_id):
|
||||
"""
|
||||
Get the system prompt variant for the experiment.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
conversation_id: The conversation ID
|
||||
|
||||
Returns:
|
||||
str or None: The PostHog variant name or None if experiment is not enabled or error occurs
|
||||
"""
|
||||
# No-op if the specific experiment is not enabled
|
||||
if not EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
|
||||
logger.info(
|
||||
'experiment_manager_002:ab_testing:skipped',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'reason': 'experiment_not_enabled',
|
||||
'experiment': EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
# Use experiment name as the flag key
|
||||
try:
|
||||
enabled_variant = posthog.get_feature_flag(
|
||||
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT, conversation_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'experiment_manager:get_feature_flag:failed',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'experiment': EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
# Store the experiment assignment in the database
|
||||
try:
|
||||
experiment_store = ExperimentAssignmentStore()
|
||||
experiment_store.update_experiment_variant(
|
||||
conversation_id=conversation_id,
|
||||
experiment_name='system_prompt_experiment',
|
||||
variant=enabled_variant,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'experiment_manager:store_assignment:failed',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'experiment': EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
|
||||
'variant': enabled_variant,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
# Fail the experiment if we cannot track the splits - results would not be explainable
|
||||
return None
|
||||
|
||||
# Log the experiment event
|
||||
# If this is a feature environment, add "FEATURE_" prefix to user_id for PostHog
|
||||
posthog_user_id = f'FEATURE_{user_id}' if IS_FEATURE_ENV else user_id
|
||||
|
||||
try:
|
||||
posthog.capture(
|
||||
distinct_id=posthog_user_id,
|
||||
event='system_prompt_set',
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
'original_user_id': user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'experiment_manager:posthog_capture:failed',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'experiment': EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
# Continue execution as this is not critical
|
||||
|
||||
logger.info(
|
||||
'posthog_capture',
|
||||
extra={
|
||||
'event': 'system_prompt_set',
|
||||
'posthog_user_id': posthog_user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
'conversation_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
},
|
||||
)
|
||||
|
||||
return enabled_variant
|
||||
|
||||
|
||||
def handle_system_prompt_experiment(
|
||||
user_id, conversation_id, config: OpenHandsConfig
|
||||
) -> OpenHandsConfig:
|
||||
"""
|
||||
Handle the system prompt experiment for OpenHands config.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
conversation_id: The conversation ID
|
||||
config: The OpenHands configuration
|
||||
|
||||
Returns:
|
||||
Modified OpenHands configuration
|
||||
"""
|
||||
enabled_variant = _get_system_prompt_variant(user_id, conversation_id)
|
||||
|
||||
# If variant is None, experiment is not enabled or there was an error
|
||||
if enabled_variant is None:
|
||||
return config
|
||||
|
||||
# Deep copy the config to avoid modifying the original
|
||||
modified_config = copy.deepcopy(config)
|
||||
|
||||
# Set the system prompt filename based on the variant
|
||||
if enabled_variant == 'control':
|
||||
# Use the long-horizon system prompt for the control variant
|
||||
agent_config = modified_config.get_agent_config(modified_config.default_agent)
|
||||
agent_config.system_prompt_filename = 'system_prompt_long_horizon.j2'
|
||||
agent_config.enable_plan_mode = True
|
||||
elif enabled_variant == 'interactive':
|
||||
modified_config.get_agent_config(
|
||||
modified_config.default_agent
|
||||
).system_prompt_filename = 'system_prompt_interactive.j2'
|
||||
elif enabled_variant == 'no_tools':
|
||||
modified_config.get_agent_config(
|
||||
modified_config.default_agent
|
||||
).system_prompt_filename = 'system_prompt.j2'
|
||||
else:
|
||||
logger.error(
|
||||
'system_prompt_experiment:unknown_variant',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'convo_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
'reason': 'no explicit mapping; returning original config',
|
||||
},
|
||||
)
|
||||
return config
|
||||
|
||||
# Log which prompt is being used
|
||||
logger.info(
|
||||
'system_prompt_experiment:prompt_selected',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'convo_id': conversation_id,
|
||||
'system_prompt_filename': modified_config.get_agent_config(
|
||||
modified_config.default_agent
|
||||
).system_prompt_filename,
|
||||
'variant': enabled_variant,
|
||||
},
|
||||
)
|
||||
|
||||
return modified_config
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
LiteLLM model experiment handler.
|
||||
|
||||
This module contains the handler for the LiteLLM model experiment.
|
||||
"""
|
||||
|
||||
import posthog
|
||||
from experiments.constants import EXPERIMENT_CLAUDE4_VS_GPT5
|
||||
from server.constants import (
|
||||
IS_FEATURE_ENV,
|
||||
build_litellm_proxy_model_path,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from storage.experiment_assignment_store import ExperimentAssignmentStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
def _get_model_variant(user_id: str | None, conversation_id: str) -> str | None:
|
||||
if not EXPERIMENT_CLAUDE4_VS_GPT5:
|
||||
logger.info(
|
||||
'experiment_manager:ab_testing:skipped',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'reason': 'experiment_not_enabled',
|
||||
'experiment': EXPERIMENT_CLAUDE4_VS_GPT5,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
enabled_variant = posthog.get_feature_flag(
|
||||
EXPERIMENT_CLAUDE4_VS_GPT5, conversation_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'experiment_manager:get_feature_flag:failed',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'experiment': EXPERIMENT_CLAUDE4_VS_GPT5,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
# Store the experiment assignment in the database
|
||||
try:
|
||||
experiment_store = ExperimentAssignmentStore()
|
||||
experiment_store.update_experiment_variant(
|
||||
conversation_id=conversation_id,
|
||||
experiment_name='claude4_vs_gpt5_experiment',
|
||||
variant=enabled_variant,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'experiment_manager:store_assignment:failed',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'experiment': EXPERIMENT_CLAUDE4_VS_GPT5,
|
||||
'variant': enabled_variant,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
# Fail the experiment if we cannot track the splits - results would not be explainable
|
||||
return None
|
||||
|
||||
# Log the experiment event
|
||||
# If this is a feature environment, add "FEATURE_" prefix to user_id for PostHog
|
||||
posthog_user_id = f'FEATURE_{user_id}' if IS_FEATURE_ENV else user_id
|
||||
|
||||
try:
|
||||
posthog.capture(
|
||||
distinct_id=posthog_user_id,
|
||||
event='claude4_or_gpt5_set',
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
'original_user_id': user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'experiment_manager:posthog_capture:failed',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'experiment': EXPERIMENT_CLAUDE4_VS_GPT5,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
# Continue execution as this is not critical
|
||||
|
||||
logger.info(
|
||||
'posthog_capture',
|
||||
extra={
|
||||
'event': 'claude4_or_gpt5_set',
|
||||
'posthog_user_id': posthog_user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
'conversation_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
},
|
||||
)
|
||||
|
||||
return enabled_variant
|
||||
|
||||
|
||||
def handle_claude4_vs_gpt5_experiment(
|
||||
user_id: str | None,
|
||||
conversation_id: str,
|
||||
conversation_settings: ConversationInitData,
|
||||
) -> ConversationInitData:
|
||||
"""
|
||||
Handle the LiteLLM model experiment.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
conversation_id: The conversation ID
|
||||
conversation_settings: The conversation settings
|
||||
|
||||
Returns:
|
||||
Modified conversation settings
|
||||
"""
|
||||
|
||||
enabled_variant = _get_model_variant(user_id, conversation_id)
|
||||
|
||||
if not enabled_variant:
|
||||
return conversation_settings
|
||||
|
||||
# Set the model based on the feature flag variant
|
||||
if enabled_variant == 'gpt5':
|
||||
model = build_litellm_proxy_model_path('gpt-5-2025-08-07')
|
||||
conversation_settings.llm_model = model
|
||||
else:
|
||||
conversation_settings.llm_model = get_default_litellm_model()
|
||||
|
||||
return conversation_settings
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Condenser max step experiment handler.
|
||||
|
||||
This module contains the handler for the condenser max step experiment that tests
|
||||
different max_size values for the condenser configuration.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import posthog
|
||||
from experiments.constants import EXPERIMENT_CONDENSER_MAX_STEP
|
||||
from server.constants import IS_FEATURE_ENV
|
||||
from storage.experiment_assignment_store import ExperimentAssignmentStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk import Agent
|
||||
from openhands.sdk.context.condenser import (
|
||||
LLMSummarizingCondenser,
|
||||
)
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
def _get_condenser_max_step_variant(user_id, conversation_id):
|
||||
"""
|
||||
Get the condenser max step variant for the experiment.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
conversation_id: The conversation ID
|
||||
|
||||
Returns:
|
||||
str or None: The PostHog variant name or None if experiment is not enabled or error occurs
|
||||
"""
|
||||
# No-op if the specific experiment is not enabled
|
||||
if not EXPERIMENT_CONDENSER_MAX_STEP:
|
||||
logger.info(
|
||||
'experiment_manager_004:ab_testing:skipped',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'reason': 'experiment_not_enabled',
|
||||
'experiment': EXPERIMENT_CONDENSER_MAX_STEP,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
# Use experiment name as the flag key
|
||||
try:
|
||||
enabled_variant = posthog.get_feature_flag(
|
||||
EXPERIMENT_CONDENSER_MAX_STEP, conversation_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'experiment_manager:get_feature_flag:failed',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'experiment': EXPERIMENT_CONDENSER_MAX_STEP,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
# Store the experiment assignment in the database
|
||||
try:
|
||||
experiment_store = ExperimentAssignmentStore()
|
||||
experiment_store.update_experiment_variant(
|
||||
conversation_id=conversation_id,
|
||||
experiment_name='condenser_max_step_experiment',
|
||||
variant=enabled_variant,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'experiment_manager:store_assignment:failed',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'experiment': EXPERIMENT_CONDENSER_MAX_STEP,
|
||||
'variant': enabled_variant,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
# Fail the experiment if we cannot track the splits - results would not be explainable
|
||||
return None
|
||||
|
||||
# Log the experiment event
|
||||
# If this is a feature environment, add "FEATURE_" prefix to user_id for PostHog
|
||||
posthog_user_id = f'FEATURE_{user_id}' if IS_FEATURE_ENV else user_id
|
||||
|
||||
try:
|
||||
posthog.capture(
|
||||
distinct_id=posthog_user_id,
|
||||
event='condenser_max_step_set',
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
'original_user_id': user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'experiment_manager:posthog_capture:failed',
|
||||
extra={
|
||||
'convo_id': conversation_id,
|
||||
'experiment': EXPERIMENT_CONDENSER_MAX_STEP,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
# Continue execution as this is not critical
|
||||
|
||||
logger.info(
|
||||
'posthog_capture',
|
||||
extra={
|
||||
'event': 'condenser_max_step_set',
|
||||
'posthog_user_id': posthog_user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
'conversation_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
},
|
||||
)
|
||||
|
||||
return enabled_variant
|
||||
|
||||
|
||||
def handle_condenser_max_step_experiment(
|
||||
user_id: str | None,
|
||||
conversation_id: str,
|
||||
conversation_settings: ConversationInitData,
|
||||
) -> ConversationInitData:
|
||||
"""
|
||||
Handle the condenser max step experiment for conversation settings.
|
||||
|
||||
We should not modify persistent user settings. Instead, apply the experiment
|
||||
variant to the conversation's in-memory settings object for this session only.
|
||||
|
||||
Variants:
|
||||
- control -> condenser_max_size = 120
|
||||
- treatment -> condenser_max_size = 80
|
||||
|
||||
Returns the (potentially) modified conversation_settings.
|
||||
"""
|
||||
|
||||
enabled_variant = _get_condenser_max_step_variant(user_id, conversation_id)
|
||||
|
||||
if enabled_variant is None:
|
||||
return conversation_settings
|
||||
|
||||
if enabled_variant == 'control':
|
||||
condenser_max_size = 120
|
||||
elif enabled_variant == 'treatment':
|
||||
condenser_max_size = 80
|
||||
else:
|
||||
logger.error(
|
||||
'condenser_max_step_experiment:unknown_variant',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'convo_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
'reason': 'unknown variant; returning original conversation settings',
|
||||
},
|
||||
)
|
||||
return conversation_settings
|
||||
|
||||
try:
|
||||
# Apply the variant to this conversation only; do not persist to DB.
|
||||
# Not all OpenHands versions expose `condenser_max_size` on settings.
|
||||
if hasattr(conversation_settings, 'condenser_max_size'):
|
||||
conversation_settings.condenser_max_size = condenser_max_size
|
||||
logger.info(
|
||||
'condenser_max_step_experiment:conversation_settings_applied',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'convo_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
'condenser_max_size': condenser_max_size,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'condenser_max_step_experiment:field_missing_on_settings',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'convo_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
'reason': 'condenser_max_size not present on ConversationInitData',
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'condenser_max_step_experiment:apply_failed',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'convo_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
return conversation_settings
|
||||
|
||||
return conversation_settings
|
||||
|
||||
|
||||
def handle_condenser_max_step_experiment__v1(
|
||||
user_id: str | None,
|
||||
conversation_id: UUID,
|
||||
agent: Agent,
|
||||
) -> Agent:
|
||||
enabled_variant = _get_condenser_max_step_variant(user_id, str(conversation_id))
|
||||
|
||||
if enabled_variant is None:
|
||||
return agent
|
||||
|
||||
if enabled_variant == 'control':
|
||||
condenser_max_size = 120
|
||||
elif enabled_variant == 'treatment':
|
||||
condenser_max_size = 80
|
||||
else:
|
||||
logger.error(
|
||||
'condenser_max_step_experiment:unknown_variant',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'convo_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
'reason': 'unknown variant; returning original conversation settings',
|
||||
},
|
||||
)
|
||||
return agent
|
||||
|
||||
condenser_llm = agent.llm.model_copy(update={'usage_id': 'condenser'})
|
||||
condenser = LLMSummarizingCondenser(
|
||||
llm=condenser_llm, max_size=condenser_max_size, keep_first=4
|
||||
)
|
||||
|
||||
return agent.model_copy(update={'condenser': condenser})
|
||||
25
enterprise/experiments/experiment_versions/__init__.py
Normal file
25
enterprise/experiments/experiment_versions/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Experiment versions package.
|
||||
|
||||
This package contains handlers for different experiment versions.
|
||||
"""
|
||||
|
||||
from experiments.experiment_versions._001_litellm_default_model_experiment import (
|
||||
handle_litellm_default_model_experiment,
|
||||
)
|
||||
from experiments.experiment_versions._002_system_prompt_experiment import (
|
||||
handle_system_prompt_experiment,
|
||||
)
|
||||
from experiments.experiment_versions._003_llm_claude4_vs_gpt5_experiment import (
|
||||
handle_claude4_vs_gpt5_experiment,
|
||||
)
|
||||
from experiments.experiment_versions._004_condenser_max_step_experiment import (
|
||||
handle_condenser_max_step_experiment,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'handle_litellm_default_model_experiment',
|
||||
'handle_system_prompt_experiment',
|
||||
'handle_claude4_vs_gpt5_experiment',
|
||||
'handle_condenser_max_step_experiment',
|
||||
]
|
||||
@@ -1,65 +0,0 @@
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.bitbucket_data_center.bitbucket_dc_service import (
|
||||
BitbucketDCService,
|
||||
)
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
class SaaSBitbucketDCService(BitbucketDCService):
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
external_auth_token: SecretStr | None = None,
|
||||
external_auth_id: str | None = None,
|
||||
token: SecretStr | None = None,
|
||||
external_token_manager: bool = False,
|
||||
base_domain: str | None = None,
|
||||
):
|
||||
logger.debug(
|
||||
f'SaaSBitbucketDCService created with user_id {user_id}, external_auth_id {external_auth_id}, external_auth_token {'set' if external_auth_token else 'None'}, token {'set' if token else 'None'}, external_token_manager {external_token_manager}'
|
||||
)
|
||||
super().__init__(
|
||||
user_id=user_id,
|
||||
external_auth_token=external_auth_token,
|
||||
external_auth_id=external_auth_id,
|
||||
token=token,
|
||||
external_token_manager=external_token_manager,
|
||||
base_domain=base_domain,
|
||||
)
|
||||
|
||||
self.token_manager = TokenManager(external=external_token_manager)
|
||||
self.refresh = True
|
||||
|
||||
async def get_latest_token(self) -> SecretStr | None:
|
||||
bitbucket_dc_token = None
|
||||
if self.external_auth_token:
|
||||
bitbucket_dc_token = SecretStr(
|
||||
await self.token_manager.get_idp_token(
|
||||
self.external_auth_token.get_secret_value(),
|
||||
idp=ProviderType.BITBUCKET_DATA_CENTER,
|
||||
)
|
||||
)
|
||||
logger.debug('Got Bitbucket DC token via external_auth_token')
|
||||
elif self.external_auth_id:
|
||||
offline_token = await self.token_manager.load_offline_token(
|
||||
self.external_auth_id
|
||||
)
|
||||
bitbucket_dc_token = SecretStr(
|
||||
await self.token_manager.get_idp_token_from_offline_token(
|
||||
offline_token, ProviderType.BITBUCKET_DATA_CENTER
|
||||
)
|
||||
)
|
||||
logger.debug('Got Bitbucket DC token via external_auth_id')
|
||||
elif self.user_id:
|
||||
bitbucket_dc_token = SecretStr(
|
||||
await self.token_manager.get_idp_token_from_idp_user_id(
|
||||
self.user_id, ProviderType.BITBUCKET_DATA_CENTER
|
||||
)
|
||||
)
|
||||
logger.debug('Got Bitbucket DC token via user_id')
|
||||
else:
|
||||
logger.warning('external_auth_token and user_id not set!')
|
||||
return bitbucket_dc_token
|
||||
@@ -116,8 +116,10 @@ class GitHubDataCollector:
|
||||
|
||||
return suffix
|
||||
|
||||
def _get_installation_access_token(self, installation_id: int) -> str:
|
||||
token_data = self.github_integration.get_access_token(installation_id)
|
||||
def _get_installation_access_token(self, installation_id: str) -> str:
|
||||
token_data = self.github_integration.get_access_token(
|
||||
installation_id # type: ignore[arg-type]
|
||||
)
|
||||
return token_data.token
|
||||
|
||||
def _check_openhands_author(self, name, login) -> bool:
|
||||
@@ -132,7 +134,7 @@ class GitHubDataCollector:
|
||||
)
|
||||
|
||||
def _get_issue_comments(
|
||||
self, installation_id: int, repo_name: str, issue_number: int, conversation_id
|
||||
self, installation_id: str, repo_name: str, issue_number: int, conversation_id
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Retrieve all comments from an issue until a comment with conversation_id is found
|
||||
@@ -232,7 +234,7 @@ class GitHubDataCollector:
|
||||
f'[Github]: Saved issue #{issue_number} for {github_view.full_repo_name}'
|
||||
)
|
||||
|
||||
def _get_pr_commits(self, installation_id: int, repo_name: str, pr_number: int):
|
||||
def _get_pr_commits(self, installation_id: str, repo_name: str, pr_number: int):
|
||||
commits = []
|
||||
installation_token = self._get_installation_access_token(installation_id)
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
@@ -429,7 +431,7 @@ class GitHubDataCollector:
|
||||
- Num openhands review comments
|
||||
"""
|
||||
pr_number = openhands_pr.pr_number
|
||||
installation_id = int(openhands_pr.installation_id)
|
||||
installation_id = openhands_pr.installation_id
|
||||
repo_id = openhands_pr.repo_id
|
||||
|
||||
# Get installation token and create Github client
|
||||
@@ -567,7 +569,7 @@ class GitHubDataCollector:
|
||||
openhands_helped_author = openhands_commit_count > 0
|
||||
|
||||
# Update the PR with OpenHands statistics
|
||||
update_success = await store.update_pr_openhands_stats(
|
||||
update_success = store.update_pr_openhands_stats(
|
||||
repo_id=repo_id,
|
||||
pr_number=pr_number,
|
||||
original_updated_at=openhands_pr.updated_at,
|
||||
@@ -610,7 +612,7 @@ class GitHubDataCollector:
|
||||
action = payload.get('action', '')
|
||||
return action == 'closed' and 'pull_request' in payload
|
||||
|
||||
async def _track_closed_or_merged_pr(self, payload):
|
||||
def _track_closed_or_merged_pr(self, payload):
|
||||
"""
|
||||
Track PR closed/merged event
|
||||
"""
|
||||
@@ -669,17 +671,17 @@ class GitHubDataCollector:
|
||||
num_general_comments=num_general_comments,
|
||||
)
|
||||
|
||||
await store.insert_pr(pr)
|
||||
store.insert_pr(pr)
|
||||
logger.info(f'Tracked PR {status}: {repo_id}#{pr_number}')
|
||||
|
||||
async def process_payload(self, message: Message):
|
||||
def process_payload(self, message: Message):
|
||||
if not COLLECT_GITHUB_INTERACTIONS:
|
||||
return
|
||||
|
||||
raw_payload = message.message.get('payload', {})
|
||||
|
||||
if self._is_pr_closed_or_merged(raw_payload):
|
||||
await self._track_closed_or_merged_pr(raw_payload)
|
||||
self._track_closed_or_merged_pr(raw_payload)
|
||||
|
||||
async def save_data(self, github_view: ResolverViewInterface):
|
||||
if not COLLECT_GITHUB_INTERACTIONS:
|
||||
|
||||
@@ -10,7 +10,6 @@ from integrations.github.github_view import (
|
||||
GithubIssue,
|
||||
GithubIssueComment,
|
||||
GithubPRComment,
|
||||
GithubViewType,
|
||||
)
|
||||
from integrations.manager import Manager
|
||||
from integrations.models import (
|
||||
@@ -20,11 +19,9 @@ from integrations.models import (
|
||||
from integrations.types import ResolverViewInterface
|
||||
from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
ENABLE_SOLVABILITY_ANALYSIS,
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
get_user_not_found_message,
|
||||
)
|
||||
from integrations.v1_utils import get_saas_user_auth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
@@ -43,9 +40,10 @@ from openhands.server.types import (
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
|
||||
class GithubManager(Manager[GithubViewType]):
|
||||
class GithubManager(Manager):
|
||||
def __init__(
|
||||
self, token_manager: TokenManager, data_collector: GitHubDataCollector
|
||||
):
|
||||
@@ -69,8 +67,11 @@ class GithubManager(Manager[GithubViewType]):
|
||||
|
||||
return f'{owner}/{repo_name}'
|
||||
|
||||
def _get_installation_access_token(self, installation_id: int) -> str:
|
||||
token_data = self.github_integration.get_access_token(installation_id)
|
||||
def _get_installation_access_token(self, installation_id: str) -> str:
|
||||
# get_access_token is typed to only accept int, but it can handle str.
|
||||
token_data = self.github_integration.get_access_token(
|
||||
installation_id # type: ignore[arg-type]
|
||||
)
|
||||
return token_data.token
|
||||
|
||||
def _add_reaction(
|
||||
@@ -125,76 +126,6 @@ class GithubManager(Manager[GithubViewType]):
|
||||
|
||||
return False
|
||||
|
||||
def _get_issue_number_from_payload(self, message: Message) -> int | None:
|
||||
"""Extract issue/PR number from a GitHub webhook payload.
|
||||
|
||||
Supports all event types that can trigger jobs:
|
||||
- Labeled issues: payload['issue']['number']
|
||||
- Issue comments: payload['issue']['number']
|
||||
- PR comments: payload['issue']['number'] (PRs are accessed via issue endpoint)
|
||||
- Inline PR comments: payload['pull_request']['number']
|
||||
|
||||
Args:
|
||||
message: The incoming GitHub webhook message
|
||||
|
||||
Returns:
|
||||
The issue/PR number, or None if not found
|
||||
"""
|
||||
payload = message.message.get('payload', {})
|
||||
|
||||
# Labeled issues, issue comments, and PR comments all have 'issue' in payload
|
||||
if 'issue' in payload:
|
||||
return payload['issue']['number']
|
||||
|
||||
# Inline PR comments have 'pull_request' directly in payload
|
||||
if 'pull_request' in payload:
|
||||
return payload['pull_request']['number']
|
||||
|
||||
return None
|
||||
|
||||
def _send_user_not_found_message(self, message: Message, username: str):
|
||||
"""Send a message to the user informing them they need to create an OpenHands account.
|
||||
|
||||
This method handles all supported trigger types:
|
||||
- Labeled issues (action='labeled' with openhands label)
|
||||
- Issue comments (comment containing @openhands)
|
||||
- PR comments (comment containing @openhands on a PR)
|
||||
- Inline PR review comments (comment containing @openhands)
|
||||
|
||||
Args:
|
||||
message: The incoming GitHub webhook message
|
||||
username: The GitHub username to mention in the response
|
||||
"""
|
||||
payload = message.message.get('payload', {})
|
||||
installation_id = message.message['installation']
|
||||
repo_obj = payload['repository']
|
||||
full_repo_name = self._get_full_repo_name(repo_obj)
|
||||
|
||||
# Get installation token to post the comment
|
||||
installation_token = self._get_installation_access_token(installation_id)
|
||||
|
||||
# Determine the issue/PR number based on the event type
|
||||
issue_number = self._get_issue_number_from_payload(message)
|
||||
|
||||
if not issue_number:
|
||||
logger.warning(
|
||||
f'[GitHub] Could not determine issue/PR number to send user not found message for {username}. '
|
||||
f'Payload keys: {list(payload.keys())}'
|
||||
)
|
||||
return
|
||||
|
||||
# Post the comment
|
||||
try:
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
repo = github_client.get_repo(full_repo_name)
|
||||
issue = repo.get_issue(number=issue_number)
|
||||
issue.create_comment(get_user_not_found_message(username))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[GitHub] Failed to send user not found message to {username} '
|
||||
f'on {full_repo_name}#{issue_number}: {e}'
|
||||
)
|
||||
|
||||
async def is_job_requested(self, message: Message) -> bool:
|
||||
self._confirm_incoming_source_type(message)
|
||||
|
||||
@@ -239,7 +170,7 @@ class GithubManager(Manager[GithubViewType]):
|
||||
async def receive_message(self, message: Message):
|
||||
self._confirm_incoming_source_type(message)
|
||||
try:
|
||||
await self.data_collector.process_payload(message)
|
||||
await call_sync_from_async(self.data_collector.process_payload, message)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
'[Github]: Error processing payload for gh interaction', exc_info=True
|
||||
@@ -248,20 +179,9 @@ class GithubManager(Manager[GithubViewType]):
|
||||
if await self.is_job_requested(message):
|
||||
payload = message.message.get('payload', {})
|
||||
user_id = payload['sender']['id']
|
||||
username = payload['sender']['login']
|
||||
keycloak_user_id = await self.token_manager.get_user_id_from_idp_user_id(
|
||||
user_id, ProviderType.GITHUB
|
||||
)
|
||||
|
||||
# Check if the user has an OpenHands account
|
||||
if not keycloak_user_id:
|
||||
logger.warning(
|
||||
f'[GitHub] User {username} (id={user_id}) not found in Keycloak. '
|
||||
f'User must create an OpenHands account first.'
|
||||
)
|
||||
self._send_user_not_found_message(message, username)
|
||||
return
|
||||
|
||||
github_view = await GithubFactory.create_github_view_from_payload(
|
||||
message, keycloak_user_id
|
||||
)
|
||||
@@ -273,51 +193,46 @@ class GithubManager(Manager[GithubViewType]):
|
||||
github_view.installation_id
|
||||
)
|
||||
# Store the installation token
|
||||
await self.token_manager.store_org_token(
|
||||
self.token_manager.store_org_token(
|
||||
github_view.installation_id, installation_token
|
||||
)
|
||||
# Add eyes reaction to acknowledge we've read the request
|
||||
self._add_reaction(github_view, 'eyes', installation_token)
|
||||
await self.start_job(github_view)
|
||||
|
||||
async def send_message(self, message: str, github_view: GithubViewType):
|
||||
"""Send a message to GitHub.
|
||||
|
||||
Args:
|
||||
message: The message content to send (plain text string)
|
||||
github_view: The GitHub view object containing issue/PR/comment info
|
||||
"""
|
||||
installation_token = await self.token_manager.load_org_token(
|
||||
async def send_message(self, message: Message, github_view: ResolverViewInterface):
|
||||
installation_token = self.token_manager.load_org_token(
|
||||
github_view.installation_id
|
||||
)
|
||||
if not installation_token:
|
||||
logger.warning('Missing installation token')
|
||||
return
|
||||
|
||||
outgoing_message = message.message
|
||||
|
||||
if isinstance(github_view, GithubInlinePRComment):
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
repo = github_client.get_repo(github_view.full_repo_name)
|
||||
pr = repo.get_pull(github_view.issue_number)
|
||||
pr.create_review_comment_reply(
|
||||
comment_id=github_view.comment_id, body=message
|
||||
comment_id=github_view.comment_id, body=outgoing_message
|
||||
)
|
||||
|
||||
elif isinstance(
|
||||
github_view, (GithubPRComment, GithubIssueComment, GithubIssue)
|
||||
elif (
|
||||
isinstance(github_view, GithubPRComment)
|
||||
or isinstance(github_view, GithubIssueComment)
|
||||
or isinstance(github_view, GithubIssue)
|
||||
):
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
repo = github_client.get_repo(github_view.full_repo_name)
|
||||
issue = repo.get_issue(number=github_view.issue_number)
|
||||
issue.create_comment(message)
|
||||
issue.create_comment(outgoing_message)
|
||||
|
||||
else:
|
||||
# Catch any new types added to GithubViewType that aren't handled above
|
||||
logger.warning( # type: ignore[unreachable]
|
||||
f'Unsupported github_view type: {type(github_view).__name__}'
|
||||
)
|
||||
logger.warning('Unsupported location')
|
||||
return
|
||||
|
||||
async def start_job(self, github_view: GithubViewType) -> None:
|
||||
async def start_job(self, github_view: ResolverViewInterface):
|
||||
"""Kick off a job with openhands agent.
|
||||
|
||||
1. Get user credential
|
||||
@@ -330,7 +245,7 @@ class GithubManager(Manager[GithubViewType]):
|
||||
)
|
||||
|
||||
try:
|
||||
msg_info: str = ''
|
||||
msg_info = None
|
||||
|
||||
try:
|
||||
user_info = github_view.user_info
|
||||
@@ -371,19 +286,19 @@ class GithubManager(Manager[GithubViewType]):
|
||||
# 3. Once the conversation is started, its base cost will include the report's spend as well which allows us to control max budget per resolver task
|
||||
convo_metadata = await github_view.initialize_new_conversation()
|
||||
solvability_summary = None
|
||||
if not ENABLE_SOLVABILITY_ANALYSIS:
|
||||
logger.info(
|
||||
'[Github]: Solvability report feature is disabled, skipping'
|
||||
)
|
||||
else:
|
||||
try:
|
||||
try:
|
||||
if user_token:
|
||||
solvability_summary = await summarize_issue_solvability(
|
||||
github_view, user_token
|
||||
)
|
||||
except Exception as e:
|
||||
else:
|
||||
logger.warning(
|
||||
f'[Github]: Error summarizing issue solvability: {str(e)}'
|
||||
'[Github]: No user token available for solvability analysis'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Github]: Error summarizing issue solvability: {str(e)}'
|
||||
)
|
||||
|
||||
saas_user_auth = await get_saas_user_auth(
|
||||
github_view.user_info.keycloak_user_id, self.token_manager
|
||||
@@ -446,13 +361,15 @@ class GithubManager(Manager[GithubViewType]):
|
||||
|
||||
msg_info = get_session_expired_message(user_info.username)
|
||||
|
||||
await self.send_message(msg_info, github_view)
|
||||
msg = self.create_outgoing_message(msg_info)
|
||||
await self.send_message(msg, github_view)
|
||||
|
||||
except Exception:
|
||||
logger.exception('[Github]: Error starting job')
|
||||
await self.send_message(
|
||||
'Uh oh! There was an unexpected error starting the job :(', github_view
|
||||
msg = self.create_outgoing_message(
|
||||
msg='Uh oh! There was an unexpected error starting the job :('
|
||||
)
|
||||
await self.send_message(msg, github_view)
|
||||
|
||||
try:
|
||||
await self.data_collector.save_data(github_view)
|
||||
|
||||
@@ -122,37 +122,13 @@ class SaaSGitHubService(GitHubService):
|
||||
raise Exception(f'No node_id found for repository {repo_id}')
|
||||
return node_id
|
||||
|
||||
async def _get_external_auth_id(self) -> str | None:
|
||||
"""Get or fetch external_auth_id from Keycloak token if not already set."""
|
||||
if self.external_auth_id:
|
||||
return self.external_auth_id
|
||||
|
||||
if self.external_auth_token:
|
||||
try:
|
||||
user_info = await self.token_manager.get_user_info(
|
||||
self.external_auth_token.get_secret_value()
|
||||
)
|
||||
self.external_auth_id = user_info.sub
|
||||
logger.info(
|
||||
f'Determined external_auth_id from Keycloak token: {self.external_auth_id}'
|
||||
)
|
||||
return self.external_auth_id
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Could not determine external_auth_id from token: {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_paginated_repos(self, page, per_page, sort, installation_id):
|
||||
repositories = await super().get_paginated_repos(
|
||||
page, per_page, sort, installation_id
|
||||
)
|
||||
external_auth_id = await self._get_external_auth_id()
|
||||
if external_auth_id:
|
||||
asyncio.create_task(
|
||||
store_repositories_in_db(repositories, external_auth_id)
|
||||
)
|
||||
asyncio.create_task(
|
||||
store_repositories_in_db(repositories, self.external_auth_id)
|
||||
)
|
||||
return repositories
|
||||
|
||||
async def get_all_repositories(
|
||||
@@ -160,10 +136,8 @@ class SaaSGitHubService(GitHubService):
|
||||
) -> list[Repository]:
|
||||
repositories = await super().get_all_repositories(sort, app_mode)
|
||||
# Schedule the background task without awaiting it
|
||||
external_auth_id = await self._get_external_auth_id()
|
||||
if external_auth_id:
|
||||
asyncio.create_task(
|
||||
store_repositories_in_db(repositories, external_auth_id)
|
||||
)
|
||||
asyncio.create_task(
|
||||
store_repositories_in_db(repositories, self.external_auth_id)
|
||||
)
|
||||
# Return repositories immediately
|
||||
return repositories
|
||||
|
||||
@@ -14,6 +14,7 @@ from integrations.solvability.models.summary import SolvabilitySummary
|
||||
from integrations.utils import ENABLE_SOLVABILITY_ANALYSIS
|
||||
from pydantic import ValidationError
|
||||
from server.config import get_config
|
||||
from storage.database import session_maker
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
@@ -89,6 +90,7 @@ async def summarize_issue_solvability(
|
||||
# Grab the user's information so we can load their LLM configuration
|
||||
store = SaasSettingsStore(
|
||||
user_id=github_view.user_info.keycloak_user_id,
|
||||
session_maker=session_maker,
|
||||
config=get_config(),
|
||||
)
|
||||
|
||||
@@ -106,11 +108,6 @@ async def summarize_issue_solvability(
|
||||
f'Solvability analysis disabled for user {github_view.user_info.user_id}'
|
||||
)
|
||||
|
||||
if user_settings.llm_api_key is None:
|
||||
raise ValueError(
|
||||
f'[Solvability] No LLM API key found for user {github_view.user_info.user_id}'
|
||||
)
|
||||
|
||||
try:
|
||||
llm_config = LLMConfig(
|
||||
model=user_settings.llm_model,
|
||||
|
||||
@@ -3,9 +3,8 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from github import Auth, Github, GithubException, GithubIntegration
|
||||
from integrations.utils import get_summary_instruction
|
||||
from integrations.v1_utils import handle_callback_error
|
||||
from github import Auth, Github, GithubIntegration
|
||||
from integrations.utils import CONVERSATION_URL, get_summary_instruction
|
||||
from pydantic import Field
|
||||
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
|
||||
@@ -43,6 +42,7 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for GitHub V1 integration."""
|
||||
|
||||
# Only handle ConversationStateUpdateEvent
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
@@ -78,20 +78,25 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
detail=summary,
|
||||
)
|
||||
except Exception as e:
|
||||
# Check if we have installation ID and credentials before posting
|
||||
can_post_error = bool(
|
||||
self.github_view_data.get('installation_id')
|
||||
and GITHUB_APP_CLIENT_ID
|
||||
and GITHUB_APP_PRIVATE_KEY
|
||||
)
|
||||
await handle_callback_error(
|
||||
error=e,
|
||||
conversation_id=conversation_id,
|
||||
service_name='GitHub',
|
||||
service_logger=_logger,
|
||||
can_post_error=can_post_error,
|
||||
post_error_func=self._post_summary_to_github,
|
||||
)
|
||||
_logger.exception('[GitHub V1] Error processing callback: %s', e)
|
||||
|
||||
# Only try to post error to GitHub if we have basic requirements
|
||||
try:
|
||||
# Check if we have installation ID and credentials before posting
|
||||
if (
|
||||
self.github_view_data.get('installation_id')
|
||||
and GITHUB_APP_CLIENT_ID
|
||||
and GITHUB_APP_PRIVATE_KEY
|
||||
):
|
||||
await self._post_summary_to_github(
|
||||
f'OpenHands encountered an error: **{str(e)}**.\n\n'
|
||||
f'[See the conversation]({CONVERSATION_URL.format(conversation_id)})'
|
||||
'for more information.'
|
||||
)
|
||||
except Exception as post_error:
|
||||
_logger.warning(
|
||||
'[GitHub V1] Failed to post error message to GitHub: %s', post_error
|
||||
)
|
||||
|
||||
return EventCallbackResult(
|
||||
status=EventCallbackResultStatus.ERROR,
|
||||
@@ -132,30 +137,19 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
full_repo_name = self.github_view_data['full_repo_name']
|
||||
issue_number = self.github_view_data['issue_number']
|
||||
|
||||
try:
|
||||
if self.inline_pr_comment:
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
repo = github_client.get_repo(full_repo_name)
|
||||
pr = repo.get_pull(issue_number)
|
||||
pr.create_review_comment_reply(
|
||||
comment_id=self.github_view_data.get('comment_id', ''),
|
||||
body=summary,
|
||||
)
|
||||
return
|
||||
|
||||
if self.inline_pr_comment:
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
repo = github_client.get_repo(full_repo_name)
|
||||
issue = repo.get_issue(number=issue_number)
|
||||
issue.create_comment(summary)
|
||||
except GithubException as e:
|
||||
if e.status == 410:
|
||||
_logger.info(
|
||||
'[GitHub V1] Issue/PR %s#%s was deleted, skipping summary post',
|
||||
full_repo_name,
|
||||
issue_number,
|
||||
pr = repo.get_pull(issue_number)
|
||||
pr.create_review_comment_reply(
|
||||
comment_id=self.github_view_data.get('comment_id', ''), body=summary
|
||||
)
|
||||
else:
|
||||
raise
|
||||
return
|
||||
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
repo = github_client.get_repo(full_repo_name)
|
||||
issue = repo.get_issue(number=issue_number)
|
||||
issue.create_comment(summary)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Agent / sandbox helpers
|
||||
@@ -173,8 +167,8 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
send_message_request = AskAgentRequest(question=message_content)
|
||||
|
||||
url = (
|
||||
f"{agent_server_url.rstrip('/')}"
|
||||
f"/api/conversations/{conversation_id}/ask_agent"
|
||||
f'{agent_server_url.rstrip("/")}'
|
||||
f'/api/conversations/{conversation_id}/ask_agent'
|
||||
)
|
||||
headers = {'X-Session-API-Key': session_api_key}
|
||||
payload = send_message_request.model_dump()
|
||||
@@ -236,7 +230,8 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _request_summary(self, conversation_id: UUID) -> str:
|
||||
"""Ask the agent to produce a summary of its work and return the agent response.
|
||||
"""
|
||||
Ask the agent to produce a summary of its work and return the agent response.
|
||||
|
||||
NOTE: This method now returns a string (the agent server's response text)
|
||||
and raises exceptions on errors. The wrapping into EventCallbackResult
|
||||
|
||||
@@ -24,6 +24,7 @@ from jinja2 import Environment
|
||||
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.database import session_maker
|
||||
from storage.org_store import OrgStore
|
||||
from storage.proactive_conversation_store import ProactiveConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
@@ -72,6 +73,7 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
This function checks both the global environment variable kill switch AND
|
||||
the user's individual setting. Both must be true for the function to return true.
|
||||
"""
|
||||
|
||||
# If no user ID is provided, we can't check user settings
|
||||
if not user_id:
|
||||
return False
|
||||
@@ -80,10 +82,13 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
if not ENABLE_PROACTIVE_CONVERSATION_STARTERS:
|
||||
return False
|
||||
|
||||
org = await OrgStore.get_current_org_from_keycloak_user_id(user_id)
|
||||
if not org:
|
||||
return False
|
||||
return bool(org.enable_proactive_conversation_starters)
|
||||
def _get_setting():
|
||||
org = OrgStore.get_current_org_from_keycloak_user_id(user_id)
|
||||
if not org:
|
||||
return False
|
||||
return bool(org.enable_proactive_conversation_starters)
|
||||
|
||||
return await call_sync_from_async(_get_setting)
|
||||
|
||||
|
||||
# =================================================
|
||||
@@ -148,7 +153,9 @@ class GithubIssue(ResolverViewInterface):
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def _get_user_secrets(self):
|
||||
secrets_store = SaasSecretsStore(self.user_info.keycloak_user_id, get_config())
|
||||
secrets_store = SaasSecretsStore(
|
||||
self.user_info.keycloak_user_id, session_maker, get_config()
|
||||
)
|
||||
user_secrets = await secrets_store.load()
|
||||
|
||||
return user_secrets.custom_secrets if user_secrets else None
|
||||
@@ -231,29 +238,6 @@ class GithubIssue(ResolverViewInterface):
|
||||
conversation_instructions=conversation_instructions,
|
||||
)
|
||||
|
||||
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
"""Build the initial user message for V1 resolver conversations.
|
||||
|
||||
For "issue opened" events (no specific comment body), we can simply
|
||||
concatenate the user prompt and the rendered issue context.
|
||||
|
||||
Subclasses that represent comment-driven events (issue comments, PR review
|
||||
comments, inline review comments) override this method to control ordering
|
||||
(e.g., context first, then the triggering comment, then previous comments).
|
||||
"""
|
||||
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja_env
|
||||
)
|
||||
|
||||
parts: list[str] = []
|
||||
if user_instructions.strip():
|
||||
parts.append(user_instructions.strip())
|
||||
if conversation_instructions.strip():
|
||||
parts.append(conversation_instructions.strip())
|
||||
|
||||
return '\n\n'.join(parts)
|
||||
|
||||
async def _create_v1_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
@@ -263,11 +247,13 @@ class GithubIssue(ResolverViewInterface):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[GitHub V1]: Creating V1 conversation')
|
||||
|
||||
initial_user_text = await self._get_v1_initial_user_message(jinja_env)
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja_env
|
||||
)
|
||||
|
||||
# Create the initial message request
|
||||
initial_message = SendMessageRequest(
|
||||
role='user', content=[TextContent(text=initial_user_text)]
|
||||
role='user', content=[TextContent(text=user_instructions)]
|
||||
)
|
||||
|
||||
# Create the GitHub V1 callback processor
|
||||
@@ -279,9 +265,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
# Create the V1 conversation start request with the callback processor
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=UUID(conversation_metadata.conversation_id),
|
||||
# NOTE: Resolver instructions are intended to be lower priority than the
|
||||
# system prompt, so we inject them into the initial user message.
|
||||
system_message_suffix=None,
|
||||
system_message_suffix=conversation_instructions,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
@@ -352,17 +336,6 @@ class GithubIssueComment(GithubIssue):
|
||||
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
await self._load_resolver_context()
|
||||
template = jinja_env.get_template('issue_comment_initial_message.j2')
|
||||
return template.render(
|
||||
issue_number=self.issue_number,
|
||||
issue_title=self.title,
|
||||
issue_body=self.description,
|
||||
issue_comment=self.comment_body,
|
||||
previous_comments=self.previous_comments,
|
||||
).strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class GithubPRComment(GithubIssueComment):
|
||||
@@ -389,18 +362,6 @@ class GithubPRComment(GithubIssueComment):
|
||||
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
await self._load_resolver_context()
|
||||
template = jinja_env.get_template('pr_update_initial_message.j2')
|
||||
return template.render(
|
||||
pr_number=self.issue_number,
|
||||
branch_name=self.branch_name,
|
||||
pr_title=self.title,
|
||||
pr_body=self.description,
|
||||
pr_comment=self.comment_body,
|
||||
comments=self.previous_comments,
|
||||
).strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class GithubInlinePRComment(GithubPRComment):
|
||||
@@ -447,20 +408,6 @@ class GithubInlinePRComment(GithubPRComment):
|
||||
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
await self._load_resolver_context()
|
||||
template = jinja_env.get_template('pr_update_initial_message.j2')
|
||||
return template.render(
|
||||
pr_number=self.issue_number,
|
||||
branch_name=self.branch_name,
|
||||
pr_title=self.title,
|
||||
pr_body=self.description,
|
||||
file_location=self.file_location,
|
||||
line_number=self.line_number,
|
||||
pr_comment=self.comment_body,
|
||||
comments=self.previous_comments,
|
||||
).strip()
|
||||
|
||||
def _create_github_v1_callback_processor(self):
|
||||
"""Create a V1 callback processor for GitHub integration."""
|
||||
from integrations.github.github_v1_callback_processor import (
|
||||
@@ -793,7 +740,7 @@ class GithubFactory:
|
||||
@staticmethod
|
||||
async def create_github_view_from_payload(
|
||||
message: Message, keycloak_user_id: str
|
||||
) -> GithubViewType:
|
||||
) -> ResolverViewInterface:
|
||||
"""Create the appropriate class (GithubIssue or GithubPRComment) based on the payload.
|
||||
Also return metadata about the event (e.g., action type).
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import MappingProxyType
|
||||
from typing import cast
|
||||
|
||||
from integrations.gitlab.gitlab_view import (
|
||||
GitlabFactory,
|
||||
@@ -20,7 +17,6 @@ from integrations.utils import (
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from integrations.v1_utils import get_saas_user_auth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
@@ -37,7 +33,7 @@ from openhands.server.types import (
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
class GitlabManager(Manager[GitlabViewType]):
|
||||
class GitlabManager(Manager):
|
||||
def __init__(self, token_manager: TokenManager, data_collector: None = None):
|
||||
self.token_manager = token_manager
|
||||
|
||||
@@ -71,11 +67,11 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
logger.warning(f'Got invalid keyloak user id for GitLab User {user_id}')
|
||||
return False
|
||||
|
||||
# GitLabServiceImpl returns SaaSGitLabService in enterprise context
|
||||
# Importing here prevents circular import
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(
|
||||
SaaSGitLabService, GitLabServiceImpl(external_auth_id=keycloak_user_id)
|
||||
gitlab_service: SaaSGitLabService = GitLabServiceImpl(
|
||||
external_auth_id=keycloak_user_id
|
||||
)
|
||||
|
||||
return await gitlab_service.user_has_write_access(project_id)
|
||||
@@ -125,52 +121,55 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
# Check if the user has write access to the repository
|
||||
return has_write_access
|
||||
|
||||
async def send_message(self, message: str, gitlab_view: ResolverViewInterface):
|
||||
"""Send a message to GitLab based on the view type.
|
||||
async def send_message(self, message: Message, gitlab_view: ResolverViewInterface):
|
||||
"""
|
||||
Send a message to GitLab based on the view type.
|
||||
|
||||
Args:
|
||||
message: The message content to send (plain text string)
|
||||
message: The message to send
|
||||
gitlab_view: The GitLab view object containing issue/PR/comment info
|
||||
"""
|
||||
keycloak_user_id = gitlab_view.user_info.keycloak_user_id
|
||||
|
||||
# GitLabServiceImpl returns SaaSGitLabService in enterprise context
|
||||
# Importing here prevents circular import
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(
|
||||
SaaSGitLabService, GitLabServiceImpl(external_auth_id=keycloak_user_id)
|
||||
gitlab_service: SaaSGitLabService = GitLabServiceImpl(
|
||||
external_auth_id=keycloak_user_id
|
||||
)
|
||||
|
||||
outgoing_message = message.message
|
||||
|
||||
if isinstance(gitlab_view, GitlabInlineMRComment) or isinstance(
|
||||
gitlab_view, GitlabMRComment
|
||||
):
|
||||
await gitlab_service.reply_to_mr(
|
||||
project_id=str(gitlab_view.project_id),
|
||||
merge_request_iid=str(gitlab_view.issue_number),
|
||||
discussion_id=gitlab_view.discussion_id,
|
||||
body=message,
|
||||
gitlab_view.project_id,
|
||||
gitlab_view.issue_number,
|
||||
gitlab_view.discussion_id,
|
||||
message.message,
|
||||
)
|
||||
|
||||
elif isinstance(gitlab_view, GitlabIssueComment):
|
||||
await gitlab_service.reply_to_issue(
|
||||
project_id=str(gitlab_view.project_id),
|
||||
issue_number=str(gitlab_view.issue_number),
|
||||
discussion_id=gitlab_view.discussion_id,
|
||||
body=message,
|
||||
gitlab_view.project_id,
|
||||
gitlab_view.issue_number,
|
||||
gitlab_view.discussion_id,
|
||||
outgoing_message,
|
||||
)
|
||||
elif isinstance(gitlab_view, GitlabIssue):
|
||||
await gitlab_service.reply_to_issue(
|
||||
project_id=str(gitlab_view.project_id),
|
||||
issue_number=str(gitlab_view.issue_number),
|
||||
discussion_id=None, # no discussion id, issue is tagged
|
||||
body=message,
|
||||
gitlab_view.project_id,
|
||||
gitlab_view.issue_number,
|
||||
None, # no discussion id, issue is tagged
|
||||
outgoing_message,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'[GitLab] Unsupported view type: {type(gitlab_view).__name__}'
|
||||
)
|
||||
|
||||
async def start_job(self, gitlab_view: GitlabViewType) -> None:
|
||||
async def start_job(self, gitlab_view: GitlabViewType):
|
||||
"""
|
||||
Start a job for the GitLab view.
|
||||
|
||||
@@ -215,18 +214,8 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
)
|
||||
)
|
||||
|
||||
# Initialize conversation and get metadata (following GitHub pattern)
|
||||
convo_metadata = await gitlab_view.initialize_new_conversation()
|
||||
|
||||
saas_user_auth = await get_saas_user_auth(
|
||||
gitlab_view.user_info.keycloak_user_id, self.token_manager
|
||||
)
|
||||
|
||||
await gitlab_view.create_new_conversation(
|
||||
self.jinja_env,
|
||||
secret_store.provider_tokens,
|
||||
convo_metadata,
|
||||
saas_user_auth,
|
||||
self.jinja_env, secret_store.provider_tokens
|
||||
)
|
||||
|
||||
conversation_id = gitlab_view.conversation_id
|
||||
@@ -235,19 +224,18 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
f'[GitLab] Created conversation {conversation_id} for user {user_info.username}'
|
||||
)
|
||||
|
||||
if not gitlab_view.v1_enabled:
|
||||
# Create a GitlabCallbackProcessor for this conversation
|
||||
processor = GitlabCallbackProcessor(
|
||||
gitlab_view=gitlab_view,
|
||||
send_summary_instruction=True,
|
||||
)
|
||||
# Create a GitlabCallbackProcessor for this conversation
|
||||
processor = GitlabCallbackProcessor(
|
||||
gitlab_view=gitlab_view,
|
||||
send_summary_instruction=True,
|
||||
)
|
||||
|
||||
# Register the callback processor
|
||||
register_callback_processor(conversation_id, processor)
|
||||
# Register the callback processor
|
||||
register_callback_processor(conversation_id, processor)
|
||||
|
||||
logger.info(
|
||||
f'[GitLab] Created callback processor for conversation {conversation_id}'
|
||||
)
|
||||
logger.info(
|
||||
f'[GitLab] Created callback processor for conversation {conversation_id}'
|
||||
)
|
||||
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
msg_info = f"I'm on it! {user_info.username} can [track my progress at all-hands.dev]({conversation_link})"
|
||||
@@ -274,10 +262,12 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
msg_info = get_session_expired_message(user_info.username)
|
||||
|
||||
# Send the acknowledgment message
|
||||
await self.send_message(msg_info, gitlab_view)
|
||||
msg = self.create_outgoing_message(msg_info)
|
||||
await self.send_message(msg, gitlab_view)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'[GitLab] Error starting job: {str(e)}')
|
||||
await self.send_message(
|
||||
'Uh oh! There was an unexpected error starting the job :(', gitlab_view
|
||||
msg = self.create_outgoing_message(
|
||||
msg='Uh oh! There was an unexpected error starting the job :('
|
||||
)
|
||||
await self.send_message(msg, gitlab_view)
|
||||
|
||||
@@ -185,30 +185,6 @@ class SaaSGitLabService(GitLabService):
|
||||
users_personal_projects: List of personal projects owned by the user
|
||||
repositories: List of Repository objects to store
|
||||
"""
|
||||
# If external_auth_id is not set, try to determine it from the Keycloak token
|
||||
if not self.external_auth_id and self.external_auth_token:
|
||||
try:
|
||||
user_info = await self.token_manager.get_user_info(
|
||||
self.external_auth_token.get_secret_value()
|
||||
)
|
||||
keycloak_user_id = user_info.sub
|
||||
self.external_auth_id = keycloak_user_id
|
||||
logger.info(
|
||||
f'Determined external_auth_id from Keycloak token: {self.external_auth_id}'
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
'Cannot store repository data: external_auth_id is not set and could not be determined from token',
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not self.external_auth_id:
|
||||
logger.warning(
|
||||
'Cannot store repository data: external_auth_id could not be determined'
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# First, add owned projects and groups to the database
|
||||
await self.add_owned_projects_and_groups_to_db(users_personal_projects)
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from integrations.utils import get_summary_instruction
|
||||
from integrations.v1_utils import handle_callback_error
|
||||
from pydantic import Field
|
||||
|
||||
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
|
||||
from openhands.app_server.event_callback.event_callback_models import (
|
||||
EventCallback,
|
||||
EventCallbackProcessor,
|
||||
)
|
||||
from openhands.app_server.event_callback.event_callback_result_models import (
|
||||
EventCallbackResult,
|
||||
EventCallbackResultStatus,
|
||||
)
|
||||
from openhands.app_server.event_callback.util import (
|
||||
ensure_conversation_found,
|
||||
ensure_running_sandbox,
|
||||
get_agent_server_url_from_sandbox,
|
||||
)
|
||||
from openhands.sdk import Event
|
||||
from openhands.sdk.event import ConversationStateUpdateEvent
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitlabV1CallbackProcessor(EventCallbackProcessor):
|
||||
"""Callback processor for GitLab V1 integrations."""
|
||||
|
||||
gitlab_view_data: dict[str, Any] = Field(default_factory=dict)
|
||||
should_request_summary: bool = Field(default=True)
|
||||
inline_mr_comment: bool = Field(default=False)
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
callback: EventCallback,
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for GitLab V1 integration."""
|
||||
# Only handle ConversationStateUpdateEvent
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
|
||||
# Only act when execution has finished
|
||||
if not (event.key == 'execution_status' and event.value == 'finished'):
|
||||
return None
|
||||
|
||||
_logger.info('[GitLab V1] Callback agent state was %s', event)
|
||||
_logger.info(
|
||||
'[GitLab V1] Should request summary: %s', self.should_request_summary
|
||||
)
|
||||
|
||||
if not self.should_request_summary:
|
||||
return None
|
||||
|
||||
self.should_request_summary = False
|
||||
|
||||
try:
|
||||
_logger.info(f'[GitLab V1] Requesting summary {conversation_id}')
|
||||
summary = await self._request_summary(conversation_id)
|
||||
_logger.info(
|
||||
f'[GitLab V1] Posting summary {conversation_id}',
|
||||
extra={'summary': summary},
|
||||
)
|
||||
await self._post_summary_to_gitlab(summary)
|
||||
|
||||
return EventCallbackResult(
|
||||
status=EventCallbackResultStatus.SUCCESS,
|
||||
event_callback_id=callback.id,
|
||||
event_id=event.id,
|
||||
conversation_id=conversation_id,
|
||||
detail=summary,
|
||||
)
|
||||
except Exception as e:
|
||||
can_post_error = bool(self.gitlab_view_data.get('keycloak_user_id'))
|
||||
await handle_callback_error(
|
||||
error=e,
|
||||
conversation_id=conversation_id,
|
||||
service_name='GitLab',
|
||||
service_logger=_logger,
|
||||
can_post_error=can_post_error,
|
||||
post_error_func=self._post_summary_to_gitlab,
|
||||
)
|
||||
|
||||
return EventCallbackResult(
|
||||
status=EventCallbackResultStatus.ERROR,
|
||||
event_callback_id=callback.id,
|
||||
event_id=event.id,
|
||||
conversation_id=conversation_id,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# GitLab helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _post_summary_to_gitlab(self, summary: str) -> None:
|
||||
"""Post a summary comment to the configured GitLab issue or MR."""
|
||||
# Import here to avoid circular imports
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
keycloak_user_id = self.gitlab_view_data.get('keycloak_user_id')
|
||||
if not keycloak_user_id:
|
||||
raise RuntimeError('Missing keycloak user ID for GitLab')
|
||||
|
||||
gitlab_service = SaaSGitLabService(external_auth_id=keycloak_user_id)
|
||||
|
||||
project_id = self.gitlab_view_data['project_id']
|
||||
issue_number = self.gitlab_view_data['issue_number']
|
||||
discussion_id = self.gitlab_view_data['discussion_id']
|
||||
is_mr = self.gitlab_view_data.get('is_mr', False)
|
||||
|
||||
if is_mr:
|
||||
await gitlab_service.reply_to_mr(
|
||||
project_id,
|
||||
issue_number,
|
||||
discussion_id,
|
||||
summary,
|
||||
)
|
||||
else:
|
||||
await gitlab_service.reply_to_issue(
|
||||
project_id,
|
||||
issue_number,
|
||||
discussion_id,
|
||||
summary,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Agent / sandbox helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _ask_question(
|
||||
self,
|
||||
httpx_client: httpx.AsyncClient,
|
||||
agent_server_url: str,
|
||||
conversation_id: UUID,
|
||||
session_api_key: str,
|
||||
message_content: str,
|
||||
) -> str:
|
||||
"""Send a message to the agent server via the V1 API and return response text."""
|
||||
send_message_request = AskAgentRequest(question=message_content)
|
||||
|
||||
url = (
|
||||
f"{agent_server_url.rstrip('/')}"
|
||||
f"/api/conversations/{conversation_id}/ask_agent"
|
||||
)
|
||||
headers = {'X-Session-API-Key': session_api_key}
|
||||
payload = send_message_request.model_dump()
|
||||
|
||||
try:
|
||||
response = await httpx_client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
agent_response = AskAgentResponse.model_validate(response.json())
|
||||
return agent_response.response
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = f'HTTP {e.response.status_code} error'
|
||||
try:
|
||||
error_body = e.response.text
|
||||
if error_body:
|
||||
error_detail += f': {error_body}'
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
_logger.error(
|
||||
'[GitLab V1] HTTP error sending message to %s: %s. '
|
||||
'Request payload: %s. Response headers: %s',
|
||||
url,
|
||||
error_detail,
|
||||
payload,
|
||||
dict(e.response.headers),
|
||||
exc_info=True,
|
||||
)
|
||||
raise Exception(f'Failed to send message to agent server: {error_detail}')
|
||||
|
||||
except httpx.TimeoutException:
|
||||
error_detail = f'Request timeout after 30 seconds to {url}'
|
||||
_logger.error(
|
||||
'[GitLab V1] %s. Request payload: %s',
|
||||
error_detail,
|
||||
payload,
|
||||
exc_info=True,
|
||||
)
|
||||
raise Exception(error_detail)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
error_detail = f'Request error to {url}: {str(e)}'
|
||||
_logger.error(
|
||||
'[GitLab V1] %s. Request payload: %s',
|
||||
error_detail,
|
||||
payload,
|
||||
exc_info=True,
|
||||
)
|
||||
raise Exception(error_detail)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Summary orchestration
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _request_summary(self, conversation_id: UUID) -> str:
|
||||
"""Ask the agent to produce a summary of its work and return the agent response.
|
||||
|
||||
NOTE: This method now returns a string (the agent server's response text)
|
||||
and raises exceptions on errors. The wrapping into EventCallbackResult
|
||||
is handled by __call__.
|
||||
"""
|
||||
# Import services within the method to avoid circular imports
|
||||
from openhands.app_server.config import (
|
||||
get_app_conversation_info_service,
|
||||
get_httpx_client,
|
||||
get_sandbox_service,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import (
|
||||
ADMIN,
|
||||
USER_CONTEXT_ATTR,
|
||||
)
|
||||
|
||||
# Create injector state for dependency injection
|
||||
state = InjectorState()
|
||||
setattr(state, USER_CONTEXT_ATTR, ADMIN)
|
||||
|
||||
async with (
|
||||
get_app_conversation_info_service(state) as app_conversation_info_service,
|
||||
get_sandbox_service(state) as sandbox_service,
|
||||
get_httpx_client(state) as httpx_client,
|
||||
):
|
||||
# 1. Conversation lookup
|
||||
app_conversation_info = ensure_conversation_found(
|
||||
await app_conversation_info_service.get_app_conversation_info(
|
||||
conversation_id
|
||||
),
|
||||
conversation_id,
|
||||
)
|
||||
|
||||
# 2. Sandbox lookup + validation
|
||||
sandbox = ensure_running_sandbox(
|
||||
await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
|
||||
app_conversation_info.sandbox_id,
|
||||
)
|
||||
|
||||
assert (
|
||||
sandbox.session_api_key is not None
|
||||
), f'No session API key for sandbox: {sandbox.id}'
|
||||
|
||||
# 3. URL + instruction
|
||||
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
|
||||
|
||||
# Prepare message based on agent state
|
||||
message_content = get_summary_instruction()
|
||||
|
||||
# Ask the agent and return the response text
|
||||
return await self._ask_question(
|
||||
httpx_client=httpx_client,
|
||||
agent_server_url=agent_server_url,
|
||||
conversation_id=conversation_id,
|
||||
session_api_key=sandbox.session_api_key,
|
||||
message_content=message_content,
|
||||
)
|
||||
@@ -1,53 +1,25 @@
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_V1_GITLAB_RESOLVER,
|
||||
HOST,
|
||||
get_oh_labels,
|
||||
get_user_v1_enabled_setting,
|
||||
has_exact_mention,
|
||||
)
|
||||
from integrations.utils import HOST, get_oh_labels, has_exact_mention
|
||||
from jinja2 import Environment
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.database import session_maker
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
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 (
|
||||
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.server.services.conversation_service import create_new_conversation
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
CONFIDENTIAL_NOTE = 'confidential_note'
|
||||
NOTE_TYPES = ['note', CONFIDENTIAL_NOTE]
|
||||
|
||||
|
||||
async def is_v1_enabled_for_gitlab_resolver(user_id: str) -> bool:
|
||||
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_GITLAB_RESOLVER
|
||||
|
||||
|
||||
# =================================================
|
||||
# SECTION: Factory to create appriorate Gitlab view
|
||||
# =================================================
|
||||
@@ -69,10 +41,6 @@ class GitlabIssue(ResolverViewInterface):
|
||||
description: str
|
||||
previous_comments: list[Comment]
|
||||
is_mr: bool
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_branch_name(self) -> str | None:
|
||||
return getattr(self, 'branch_name', None)
|
||||
|
||||
async def _load_resolver_context(self):
|
||||
gitlab_service = GitLabServiceImpl(
|
||||
@@ -110,158 +78,35 @@ class GitlabIssue(ResolverViewInterface):
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def _get_user_secrets(self):
|
||||
secrets_store = SaasSecretsStore(self.user_info.keycloak_user_id, get_config())
|
||||
secrets_store = SaasSecretsStore(
|
||||
self.user_info.keycloak_user_id, session_maker, get_config()
|
||||
)
|
||||
user_secrets = await secrets_store.load()
|
||||
|
||||
return user_secrets.custom_secrets if user_secrets else None
|
||||
|
||||
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
|
||||
if self.v1_enabled:
|
||||
# Create dummy conversation metadata
|
||||
# Don't save to conversation store
|
||||
# V1 conversations are stored in a separate table
|
||||
self.conversation_id = uuid4().hex
|
||||
return ConversationMetadata(
|
||||
conversation_id=self.conversation_id,
|
||||
selected_repository=self.full_repo_name,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
self.conversation_id = conversation_metadata.conversation_id
|
||||
return conversation_metadata
|
||||
|
||||
async def create_new_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
saas_user_auth: UserAuth,
|
||||
self, jinja_env: Environment, git_provider_tokens: PROVIDER_TOKEN_TYPE
|
||||
):
|
||||
# v1_enabled is already set at construction time in the factory method
|
||||
if self.v1_enabled:
|
||||
# Use V1 app conversation service
|
||||
await self._create_v1_conversation(
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
)
|
||||
else:
|
||||
await self._create_v0_conversation(
|
||||
jinja_env, git_provider_tokens, conversation_metadata
|
||||
)
|
||||
|
||||
async def _create_v0_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the legacy V0 system."""
|
||||
logger.info('[GitLab]: Creating V0 conversation')
|
||||
custom_secrets = await self._get_user_secrets()
|
||||
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja_env
|
||||
)
|
||||
|
||||
await start_conversation(
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
git_provider_tokens=git_provider_tokens,
|
||||
custom_secrets=custom_secrets,
|
||||
initial_user_msg=user_instructions,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_id=conversation_metadata.conversation_id,
|
||||
conversation_metadata=conversation_metadata,
|
||||
conversation_instructions=conversation_instructions,
|
||||
)
|
||||
|
||||
async def _create_v1_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
saas_user_auth: UserAuth,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[GitLab V1]: Creating V1 conversation')
|
||||
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja_env
|
||||
)
|
||||
|
||||
# Create the initial message request
|
||||
initial_message = SendMessageRequest(
|
||||
role='user', content=[TextContent(text=user_instructions)]
|
||||
)
|
||||
|
||||
# Create the GitLab V1 callback processor
|
||||
gitlab_callback_processor = self._create_gitlab_v1_callback_processor()
|
||||
|
||||
# Get the app conversation service and start the conversation
|
||||
injector_state = InjectorState()
|
||||
|
||||
# Determine the title based on whether it's an MR or issue
|
||||
title_prefix = 'GitLab MR' if self.is_mr else 'GitLab Issue'
|
||||
title = f'{title_prefix} #{self.issue_number}: {self.title}'
|
||||
|
||||
# Create the V1 conversation start request with the callback processor
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=UUID(conversation_metadata.conversation_id),
|
||||
system_message_suffix=conversation_instructions,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
title=title,
|
||||
trigger=ConversationTrigger.RESOLVER,
|
||||
processors=[
|
||||
gitlab_callback_processor
|
||||
], # Pass the callback processor directly
|
||||
)
|
||||
|
||||
# Set up the GitLab user context for the V1 system
|
||||
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(
|
||||
injector_state
|
||||
) as app_conversation_service:
|
||||
async for task in app_conversation_service.start_app_conversation(
|
||||
start_request
|
||||
):
|
||||
if task.status == AppConversationStartTaskStatus.ERROR:
|
||||
logger.error(f'Failed to start V1 conversation: {task.detail}')
|
||||
raise RuntimeError(
|
||||
f'Failed to start V1 conversation: {task.detail}'
|
||||
)
|
||||
|
||||
def _create_gitlab_v1_callback_processor(self):
|
||||
"""Create a V1 callback processor for GitLab integration."""
|
||||
from integrations.gitlab.gitlab_v1_callback_processor import (
|
||||
GitlabV1CallbackProcessor,
|
||||
)
|
||||
|
||||
# Create and return the GitLab V1 callback processor
|
||||
return GitlabV1CallbackProcessor(
|
||||
gitlab_view_data={
|
||||
'issue_number': self.issue_number,
|
||||
'project_id': self.project_id,
|
||||
'full_repo_name': self.full_repo_name,
|
||||
'installation_id': self.installation_id,
|
||||
'keycloak_user_id': self.user_info.keycloak_user_id,
|
||||
'is_mr': self.is_mr,
|
||||
'discussion_id': getattr(self, 'discussion_id', None),
|
||||
},
|
||||
send_summary_instruction=self.send_summary_instruction,
|
||||
selected_branch=None,
|
||||
initial_user_msg=user_instructions,
|
||||
conversation_instructions=conversation_instructions,
|
||||
image_urls=None,
|
||||
conversation_trigger=ConversationTrigger.RESOLVER,
|
||||
replay_json=None,
|
||||
)
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
return self.conversation_id
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -296,9 +141,6 @@ class GitlabIssueComment(GitlabIssue):
|
||||
class GitlabMRComment(GitlabIssueComment):
|
||||
branch_name: str
|
||||
|
||||
def _get_branch_name(self) -> str | None:
|
||||
return self.branch_name
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
user_instructions_template = jinja_env.get_template('mr_update_prompt.j2')
|
||||
await self._load_resolver_context()
|
||||
@@ -320,6 +162,29 @@ class GitlabMRComment(GitlabIssueComment):
|
||||
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def create_new_conversation(
|
||||
self, jinja_env: Environment, git_provider_tokens: PROVIDER_TOKEN_TYPE
|
||||
):
|
||||
custom_secrets = await self._get_user_secrets()
|
||||
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja_env
|
||||
)
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
git_provider_tokens=git_provider_tokens,
|
||||
custom_secrets=custom_secrets,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self.branch_name,
|
||||
initial_user_msg=user_instructions,
|
||||
conversation_instructions=conversation_instructions,
|
||||
image_urls=None,
|
||||
conversation_trigger=ConversationTrigger.RESOLVER,
|
||||
replay_json=None,
|
||||
)
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
return self.conversation_id
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitlabInlineMRComment(GitlabMRComment):
|
||||
@@ -441,7 +306,7 @@ class GitlabFactory:
|
||||
@staticmethod
|
||||
async def create_gitlab_view_from_payload(
|
||||
message: Message, token_manager: TokenManager
|
||||
) -> GitlabViewType:
|
||||
) -> ResolverViewInterface:
|
||||
payload = message.message['payload']
|
||||
installation_id = message.message['installation_id']
|
||||
user = payload['user']
|
||||
@@ -460,16 +325,6 @@ class GitlabFactory:
|
||||
user_id=user_id, username=username, keycloak_user_id=keycloak_user_id
|
||||
)
|
||||
|
||||
# Check v1_enabled at construction time - this is the source of truth
|
||||
v1_enabled = (
|
||||
await is_v1_enabled_for_gitlab_resolver(keycloak_user_id)
|
||||
if keycloak_user_id
|
||||
else False
|
||||
)
|
||||
logger.info(
|
||||
f'[GitLab V1]: User flag found for {keycloak_user_id} is {v1_enabled}'
|
||||
)
|
||||
|
||||
if GitlabFactory.is_labeled_issue(message):
|
||||
issue_iid = payload['object_attributes']['iid']
|
||||
|
||||
@@ -491,7 +346,6 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=False,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
elif GitlabFactory.is_issue_comment(message):
|
||||
@@ -522,7 +376,6 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=False,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
elif GitlabFactory.is_mr_comment(message):
|
||||
@@ -555,7 +408,6 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=True,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
elif GitlabFactory.is_mr_comment(message, inline=True):
|
||||
@@ -596,7 +448,4 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=True,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
raise ValueError(f'Unhandled GitLab webhook event: {message}')
|
||||
|
||||
@@ -4,9 +4,7 @@ This module contains reusable functions and classes for installing GitLab webhoo
|
||||
that can be used by both the cron job and API routes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
from integrations.types import GitLabResourceType
|
||||
@@ -15,9 +13,7 @@ from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
from openhands.integrations.service_types import GitService
|
||||
|
||||
# Webhook configuration constants
|
||||
WEBHOOK_NAME = 'OpenHands Resolver'
|
||||
@@ -39,7 +35,7 @@ class BreakLoopException(Exception):
|
||||
|
||||
|
||||
async def verify_webhook_conditions(
|
||||
gitlab_service: SaaSGitLabService,
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
@@ -56,6 +52,10 @@ async def verify_webhook_conditions(
|
||||
webhook_store: Webhook store instance
|
||||
webhook: Webhook object to verify
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
# Check if resource exists
|
||||
does_resource_exist, status = await gitlab_service.check_resource_exists(
|
||||
resource_type, resource_id
|
||||
@@ -106,9 +106,7 @@ async def verify_webhook_conditions(
|
||||
does_webhook_exist_on_resource,
|
||||
status,
|
||||
) = await gitlab_service.check_webhook_exists_on_resource(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_url=GITLAB_WEBHOOK_URL,
|
||||
resource_type, resource_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -133,7 +131,7 @@ async def verify_webhook_conditions(
|
||||
|
||||
|
||||
async def install_webhook_on_resource(
|
||||
gitlab_service: SaaSGitLabService,
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
@@ -152,6 +150,10 @@ async def install_webhook_on_resource(
|
||||
Returns:
|
||||
Tuple of (webhook_id, status)
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
webhook_secret = f'{webhook.user_id}-{str(uuid4())}'
|
||||
webhook_uuid = f'{str(uuid4())}'
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
|
||||
class JiraManager(Manager[JiraViewInterface]):
|
||||
class JiraManager(Manager):
|
||||
"""Manager for processing Jira webhook events.
|
||||
|
||||
This class orchestrates the flow from webhook receipt to conversation creation,
|
||||
@@ -257,7 +257,7 @@ class JiraManager(Manager[JiraViewInterface]):
|
||||
|
||||
return jira_user, saas_user_auth
|
||||
|
||||
async def start_job(self, view: JiraViewInterface) -> None:
|
||||
async def start_job(self, view: JiraViewInterface):
|
||||
"""Start a Jira job/conversation."""
|
||||
# Import here to prevent circular import
|
||||
from server.conversation_callback_processor.jira_callback_processor import (
|
||||
@@ -341,25 +341,17 @@ class JiraManager(Manager[JiraViewInterface]):
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
message: str,
|
||||
message: Message,
|
||||
issue_key: str,
|
||||
jira_cloud_id: str,
|
||||
svc_acc_email: str,
|
||||
svc_acc_api_key: str,
|
||||
):
|
||||
"""Send a comment to a Jira issue.
|
||||
|
||||
Args:
|
||||
message: The message content to send (plain text string)
|
||||
issue_key: The Jira issue key (e.g., 'PROJ-123')
|
||||
jira_cloud_id: The Jira Cloud ID
|
||||
svc_acc_email: Service account email for authentication
|
||||
svc_acc_api_key: Service account API key for authentication
|
||||
"""
|
||||
"""Send a comment to a Jira issue."""
|
||||
url = (
|
||||
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
|
||||
)
|
||||
data = {'body': message}
|
||||
data = {'body': message.message}
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.post(
|
||||
url, auth=(svc_acc_email, svc_acc_api_key), json=data
|
||||
@@ -374,7 +366,7 @@ class JiraManager(Manager[JiraViewInterface]):
|
||||
view.jira_workspace.svc_acc_api_key
|
||||
)
|
||||
await self.send_message(
|
||||
msg,
|
||||
self.create_outgoing_message(msg=msg),
|
||||
issue_key=view.payload.issue_key,
|
||||
jira_cloud_id=view.jira_workspace.jira_cloud_id,
|
||||
svc_acc_email=view.jira_workspace.svc_acc_email,
|
||||
@@ -396,7 +388,7 @@ class JiraManager(Manager[JiraViewInterface]):
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
|
||||
await self.send_message(
|
||||
error_msg,
|
||||
self.create_outgoing_message(msg=error_msg),
|
||||
issue_key=payload.issue_key,
|
||||
jira_cloud_id=workspace.jira_cloud_id,
|
||||
svc_acc_email=workspace.svc_acc_email,
|
||||
|
||||
@@ -212,6 +212,8 @@ class JiraPayloadParser:
|
||||
missing.append('issue.id')
|
||||
if not issue_key:
|
||||
missing.append('issue.key')
|
||||
if not user_email:
|
||||
missing.append('user.emailAddress')
|
||||
if not display_name:
|
||||
missing.append('user.displayName')
|
||||
if not account_id:
|
||||
|
||||
@@ -42,7 +42,7 @@ from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
|
||||
class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
class JiraDcManager(Manager):
|
||||
def __init__(self, token_manager: TokenManager):
|
||||
self.token_manager = token_manager
|
||||
self.integration_store = JiraDcIntegrationStore.get_instance()
|
||||
@@ -353,7 +353,7 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
logger.error(f'[Jira DC] Error in is_job_requested: {str(e)}')
|
||||
return False
|
||||
|
||||
async def start_job(self, jira_dc_view: JiraDcViewInterface) -> None:
|
||||
async def start_job(self, jira_dc_view: JiraDcViewInterface):
|
||||
"""Start a Jira DC job/conversation."""
|
||||
# Import here to prevent circular import
|
||||
from server.conversation_callback_processor.jira_dc_callback_processor import (
|
||||
@@ -418,7 +418,7 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
jira_dc_view.jira_dc_workspace.svc_acc_api_key
|
||||
)
|
||||
await self.send_message(
|
||||
msg_info,
|
||||
self.create_outgoing_message(msg=msg_info),
|
||||
issue_key=jira_dc_view.job_context.issue_key,
|
||||
base_api_url=jira_dc_view.job_context.base_api_url,
|
||||
svc_acc_api_key=api_key,
|
||||
@@ -456,19 +456,12 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
return title, description
|
||||
|
||||
async def send_message(
|
||||
self, message: str, issue_key: str, base_api_url: str, svc_acc_api_key: str
|
||||
self, message: Message, issue_key: str, base_api_url: str, svc_acc_api_key: str
|
||||
):
|
||||
"""Send message/comment to Jira DC issue.
|
||||
|
||||
Args:
|
||||
message: The message content to send (plain text string)
|
||||
issue_key: The Jira issue key (e.g., 'PROJ-123')
|
||||
base_api_url: The base API URL for the Jira DC instance
|
||||
svc_acc_api_key: Service account API key for authentication
|
||||
"""
|
||||
"""Send message/comment to Jira DC issue."""
|
||||
url = f'{base_api_url}/rest/api/2/issue/{issue_key}/comment'
|
||||
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
|
||||
data = {'body': message}
|
||||
data = {'body': message.message}
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
@@ -488,7 +481,7 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
|
||||
await self.send_message(
|
||||
error_msg,
|
||||
self.create_outgoing_message(msg=error_msg),
|
||||
issue_key=job_context.issue_key,
|
||||
base_api_url=job_context.base_api_url,
|
||||
svc_acc_api_key=api_key,
|
||||
@@ -509,7 +502,7 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
)
|
||||
|
||||
await self.send_message(
|
||||
comment_msg,
|
||||
self.create_outgoing_message(msg=comment_msg),
|
||||
issue_key=jira_dc_view.job_context.issue_key,
|
||||
base_api_url=jira_dc_view.job_context.base_api_url,
|
||||
svc_acc_api_key=api_key,
|
||||
|
||||
@@ -19,7 +19,7 @@ class JiraDcViewInterface(ABC):
|
||||
conversation_id: str
|
||||
|
||||
@abstractmethod
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Get initial instructions for the conversation."""
|
||||
pass
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class JiraDcNewConversationView(JiraDcViewInterface):
|
||||
selected_repo: str | None
|
||||
conversation_id: str
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
instructions_template = jinja_env.get_template('jira_dc_instructions.j2')
|
||||
@@ -61,7 +61,7 @@ class JiraDcNewConversationView(JiraDcViewInterface):
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
user_secrets = await self.saas_user_auth.get_secrets()
|
||||
instructions, user_msg = await self._get_instructions(jinja_env)
|
||||
instructions, user_msg = self._get_instructions(jinja_env)
|
||||
|
||||
try:
|
||||
agent_loop_info = await create_new_conversation(
|
||||
@@ -113,7 +113,7 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
selected_repo: str | None
|
||||
conversation_id: str
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
user_msg_template = jinja_env.get_template('jira_dc_existing_conversation.j2')
|
||||
@@ -155,9 +155,6 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
self.conversation_id, conversation_init_data, user_id
|
||||
)
|
||||
|
||||
if agent_loop_info.event_store is None:
|
||||
raise StartingConvoException('Event store not available')
|
||||
|
||||
final_agent_observation = get_final_agent_observation(
|
||||
agent_loop_info.event_store
|
||||
)
|
||||
@@ -170,7 +167,7 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
if not agent_state or agent_state == AgentState.LOADING:
|
||||
raise StartingConvoException('Conversation is still starting')
|
||||
|
||||
_, user_msg = await self._get_instructions(jinja_env)
|
||||
_, user_msg = self._get_instructions(jinja_env)
|
||||
user_message_event = MessageAction(content=user_msg)
|
||||
await conversation_manager.send_event_to_conversation(
|
||||
self.conversation_id, event_to_dict(user_message_event)
|
||||
|
||||
@@ -39,7 +39,7 @@ from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
|
||||
class LinearManager(Manager[LinearViewInterface]):
|
||||
class LinearManager(Manager):
|
||||
def __init__(self, token_manager: TokenManager):
|
||||
self.token_manager = token_manager
|
||||
self.integration_store = LinearIntegrationStore.get_instance()
|
||||
@@ -343,7 +343,7 @@ class LinearManager(Manager[LinearViewInterface]):
|
||||
logger.error(f'[Linear] Error in is_job_requested: {str(e)}')
|
||||
return False
|
||||
|
||||
async def start_job(self, linear_view: LinearViewInterface) -> None:
|
||||
async def start_job(self, linear_view: LinearViewInterface):
|
||||
"""Start a Linear job/conversation."""
|
||||
# Import here to prevent circular import
|
||||
from server.conversation_callback_processor.linear_callback_processor import (
|
||||
@@ -408,7 +408,7 @@ class LinearManager(Manager[LinearViewInterface]):
|
||||
linear_view.linear_workspace.svc_acc_api_key
|
||||
)
|
||||
await self.send_message(
|
||||
msg_info,
|
||||
self.create_outgoing_message(msg=msg_info),
|
||||
linear_view.job_context.issue_id,
|
||||
api_key,
|
||||
)
|
||||
@@ -473,14 +473,8 @@ class LinearManager(Manager[LinearViewInterface]):
|
||||
|
||||
return title, description
|
||||
|
||||
async def send_message(self, message: str, issue_id: str, api_key: str):
|
||||
"""Send message/comment to Linear issue.
|
||||
|
||||
Args:
|
||||
message: The message content to send (plain text string)
|
||||
issue_id: The Linear issue ID to comment on
|
||||
api_key: The Linear API key for authentication
|
||||
"""
|
||||
async def send_message(self, message: Message, issue_id: str, api_key: str):
|
||||
"""Send message/comment to Linear issue."""
|
||||
query = """
|
||||
mutation CommentCreate($input: CommentCreateInput!) {
|
||||
commentCreate(input: $input) {
|
||||
@@ -491,7 +485,7 @@ class LinearManager(Manager[LinearViewInterface]):
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {'input': {'issueId': issue_id, 'body': message}}
|
||||
variables = {'input': {'issueId': issue_id, 'body': message.message}}
|
||||
return await self._query_api(query, variables, api_key)
|
||||
|
||||
async def _send_error_comment(
|
||||
@@ -504,7 +498,9 @@ class LinearManager(Manager[LinearViewInterface]):
|
||||
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
|
||||
await self.send_message(error_msg, issue_id, api_key)
|
||||
await self.send_message(
|
||||
self.create_outgoing_message(msg=error_msg), issue_id, api_key
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'[Linear] Failed to send error comment: {str(e)}')
|
||||
|
||||
@@ -521,7 +517,7 @@ class LinearManager(Manager[LinearViewInterface]):
|
||||
)
|
||||
|
||||
await self.send_message(
|
||||
comment_msg,
|
||||
self.create_outgoing_message(msg=comment_msg),
|
||||
linear_view.job_context.issue_id,
|
||||
api_key,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ class LinearViewInterface(ABC):
|
||||
conversation_id: str
|
||||
|
||||
@abstractmethod
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Get initial instructions for the conversation."""
|
||||
pass
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class LinearNewConversationView(LinearViewInterface):
|
||||
selected_repo: str | None
|
||||
conversation_id: str
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
instructions_template = jinja_env.get_template('linear_instructions.j2')
|
||||
@@ -58,7 +58,7 @@ class LinearNewConversationView(LinearViewInterface):
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
user_secrets = await self.saas_user_auth.get_secrets()
|
||||
instructions, user_msg = await self._get_instructions(jinja_env)
|
||||
instructions, user_msg = self._get_instructions(jinja_env)
|
||||
|
||||
try:
|
||||
agent_loop_info = await create_new_conversation(
|
||||
@@ -110,7 +110,7 @@ class LinearExistingConversationView(LinearViewInterface):
|
||||
selected_repo: str | None
|
||||
conversation_id: str
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
user_msg_template = jinja_env.get_template('linear_existing_conversation.j2')
|
||||
@@ -152,9 +152,6 @@ class LinearExistingConversationView(LinearViewInterface):
|
||||
self.conversation_id, conversation_init_data, user_id
|
||||
)
|
||||
|
||||
if agent_loop_info.event_store is None:
|
||||
raise StartingConvoException('Event store not available')
|
||||
|
||||
final_agent_observation = get_final_agent_observation(
|
||||
agent_loop_info.event_store
|
||||
)
|
||||
@@ -167,7 +164,7 @@ class LinearExistingConversationView(LinearViewInterface):
|
||||
if not agent_state or agent_state == AgentState.LOADING:
|
||||
raise StartingConvoException('Conversation is still starting')
|
||||
|
||||
_, user_msg = await self._get_instructions(jinja_env)
|
||||
_, user_msg = self._get_instructions(jinja_env)
|
||||
user_message_event = MessageAction(content=user_msg)
|
||||
await conversation_manager.send_event_to_conversation(
|
||||
self.conversation_id, event_to_dict(user_message_event)
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from integrations.models import Message, SourceType
|
||||
|
||||
# TypeVar for view types - each manager subclass specifies its own view type
|
||||
ViewT = TypeVar('ViewT')
|
||||
|
||||
|
||||
class Manager(ABC, Generic[ViewT]):
|
||||
class Manager(ABC):
|
||||
manager_type: SourceType
|
||||
|
||||
@abstractmethod
|
||||
@@ -16,21 +12,14 @@ class Manager(ABC, Generic[ViewT]):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def send_message(self, message: str, *args: Any, **kwargs: Any):
|
||||
"""Send message to integration from OpenHands server.
|
||||
|
||||
Args:
|
||||
message: The message content to send (plain text string).
|
||||
"""
|
||||
def send_message(self, message: Message):
|
||||
"Send message to integration from Openhands server"
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def start_job(self, view: ViewT) -> None:
|
||||
"""Kick off a job with openhands agent.
|
||||
|
||||
Args:
|
||||
view: Integration-specific view object containing job context.
|
||||
Each manager subclass accepts its own view type
|
||||
(e.g., SlackViewInterface, JiraViewInterface, etc.)
|
||||
"""
|
||||
def start_job(self):
|
||||
"Kick off a job with openhands agent"
|
||||
raise NotImplementedError
|
||||
|
||||
def create_outgoing_message(self, msg: str | dict, ephemeral: bool = False):
|
||||
return Message(source=SourceType.OPENHANDS, message=msg, ephemeral=ephemeral)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -17,16 +16,8 @@ class SourceType(str, Enum):
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""Message model for incoming webhook payloads from integrations.
|
||||
|
||||
Note: This model is intended for INCOMING messages only.
|
||||
For outgoing messages (e.g., sending comments to GitHub/GitLab),
|
||||
pass strings directly to the send_message methods instead of
|
||||
wrapping them in a Message object.
|
||||
"""
|
||||
|
||||
source: SourceType
|
||||
message: dict[str, Any]
|
||||
message: str | dict
|
||||
ephemeral: bool = False
|
||||
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Centralized error handling for Slack integration.
|
||||
|
||||
This module provides:
|
||||
- SlackErrorCode: Unique error codes for traceability
|
||||
- SlackError: Exception class for user-facing errors
|
||||
- get_user_message(): Function to get user-facing messages for error codes
|
||||
"""
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from integrations.utils import HOST_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SlackErrorCode(Enum):
|
||||
"""Unique error codes for traceability in logs and user messages."""
|
||||
|
||||
SESSION_EXPIRED = 'SLACK_ERR_001'
|
||||
REDIS_STORE_FAILED = 'SLACK_ERR_002'
|
||||
REDIS_RETRIEVE_FAILED = 'SLACK_ERR_003'
|
||||
USER_NOT_AUTHENTICATED = 'SLACK_ERR_004'
|
||||
PROVIDER_TIMEOUT = 'SLACK_ERR_005'
|
||||
PROVIDER_AUTH_FAILED = 'SLACK_ERR_006'
|
||||
LLM_AUTH_FAILED = 'SLACK_ERR_007'
|
||||
MISSING_SETTINGS = 'SLACK_ERR_008'
|
||||
UNEXPECTED_ERROR = 'SLACK_ERR_999'
|
||||
|
||||
|
||||
class SlackError(Exception):
|
||||
"""Exception for errors that should be communicated to the Slack user.
|
||||
|
||||
This exception is caught by the centralized error handler in SlackManager,
|
||||
which logs the error and sends an appropriate message to the user.
|
||||
|
||||
Usage:
|
||||
raise SlackError(SlackErrorCode.USER_NOT_AUTHENTICATED,
|
||||
message_kwargs={'login_link': link})
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: SlackErrorCode,
|
||||
message_kwargs: dict[str, Any] | None = None,
|
||||
log_context: dict[str, Any] | None = None,
|
||||
):
|
||||
"""Initialize a SlackError.
|
||||
|
||||
Args:
|
||||
code: The error code identifying the type of error
|
||||
message_kwargs: Kwargs for formatting the user message
|
||||
(e.g., {'login_link': '...'})
|
||||
log_context: Additional context for structured logging
|
||||
"""
|
||||
self.code = code
|
||||
self.message_kwargs = message_kwargs or {}
|
||||
self.log_context = log_context or {}
|
||||
super().__init__(f'{code.value}: {code.name}')
|
||||
|
||||
def get_user_message(self) -> str:
|
||||
"""Get the user-facing message for this error."""
|
||||
return get_user_message(self.code, **self.message_kwargs)
|
||||
|
||||
|
||||
# Centralized user-facing messages
|
||||
_USER_MESSAGES: dict[SlackErrorCode, str] = {
|
||||
SlackErrorCode.SESSION_EXPIRED: (
|
||||
'⏰ Your session has expired. '
|
||||
'Please mention me again with your request to start a new conversation.'
|
||||
),
|
||||
SlackErrorCode.REDIS_STORE_FAILED: (
|
||||
'⚠️ Something went wrong on our end (ref: {code}). '
|
||||
'Please try again in a few moments.'
|
||||
),
|
||||
SlackErrorCode.REDIS_RETRIEVE_FAILED: (
|
||||
'⚠️ Something went wrong on our end (ref: {code}). '
|
||||
'Please try again in a few moments.'
|
||||
),
|
||||
SlackErrorCode.USER_NOT_AUTHENTICATED: (
|
||||
'🔐 Please link your Slack account to OpenHands: '
|
||||
'[Click here to Login]({login_link})'
|
||||
),
|
||||
SlackErrorCode.PROVIDER_TIMEOUT: (
|
||||
'⏱️ The request timed out while connecting to your git provider. '
|
||||
'Please try again.'
|
||||
),
|
||||
SlackErrorCode.PROVIDER_AUTH_FAILED: (
|
||||
'🔐 Authentication with your git provider failed. '
|
||||
f'Please re-login at [OpenHands Cloud]({HOST_URL}) and try again.'
|
||||
),
|
||||
SlackErrorCode.LLM_AUTH_FAILED: (
|
||||
'@{username} please set a valid LLM API key in '
|
||||
f'[OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
),
|
||||
SlackErrorCode.MISSING_SETTINGS: (
|
||||
'{username} please re-login into '
|
||||
f'[OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
),
|
||||
SlackErrorCode.UNEXPECTED_ERROR: (
|
||||
'Uh oh! There was an unexpected error (ref: {code}). Please try again later.'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_user_message(error_code: SlackErrorCode, **kwargs) -> str:
|
||||
"""Get a user-facing message for a given error code.
|
||||
|
||||
Args:
|
||||
error_code: The error code to get a message for
|
||||
**kwargs: Additional formatting arguments (e.g., username, login_link)
|
||||
|
||||
Returns:
|
||||
Formatted user-facing message string
|
||||
"""
|
||||
msg = _USER_MESSAGES.get(
|
||||
error_code, _USER_MESSAGES[SlackErrorCode.UNEXPECTED_ERROR]
|
||||
)
|
||||
try:
|
||||
return msg.format(code=error_code.value, **kwargs)
|
||||
except KeyError as e:
|
||||
logger.warning(
|
||||
f'Missing format key {e} in error message',
|
||||
extra={'error_code': error_code.value},
|
||||
)
|
||||
# Return a generic error message with the code for debugging
|
||||
return f'An error occurred (ref: {error_code.value}). Please try again later.'
|
||||
@@ -1,25 +1,20 @@
|
||||
from typing import Any
|
||||
import re
|
||||
|
||||
import jwt
|
||||
from integrations.manager import Manager
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.slack.slack_errors import SlackError, SlackErrorCode
|
||||
from integrations.slack.slack_types import (
|
||||
SlackMessageView,
|
||||
SlackViewInterface,
|
||||
StartingConvoException,
|
||||
)
|
||||
from integrations.slack.slack_types import SlackViewInterface, StartingConvoException
|
||||
from integrations.slack.slack_view import (
|
||||
SlackFactory,
|
||||
SlackNewConversationFromRepoFormView,
|
||||
SlackNewConversationView,
|
||||
SlackUnkownUserView,
|
||||
SlackUpdateExistingConversationView,
|
||||
)
|
||||
from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
infer_repo_from_message,
|
||||
)
|
||||
from integrations.v1_utils import get_saas_user_auth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
@@ -27,18 +22,13 @@ from server.constants import SLACK_CLIENT_ID
|
||||
from server.utils.conversation_callback_utils import register_callback_processor
|
||||
from slack_sdk.oauth import AuthorizeUrlGenerator
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
from sqlalchemy import select
|
||||
from storage.database import a_session_maker
|
||||
from storage.database import session_maker
|
||||
from storage.slack_user import SlackUser
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
ProviderTimeoutError,
|
||||
Repository,
|
||||
)
|
||||
from openhands.server.shared import config, server_config, sio
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import config, server_config
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
@@ -52,14 +42,8 @@ authorize_url_generator = AuthorizeUrlGenerator(
|
||||
user_scopes=['search:read'],
|
||||
)
|
||||
|
||||
# Key prefix for storing user messages in Redis during repo selection flow
|
||||
SLACK_USER_MSG_KEY_PREFIX = 'slack_user_msg'
|
||||
# Expiration time for stored user messages (5 minutes)
|
||||
# Arbitrary timeout based on typical user attention span; may be tuned based on feedback
|
||||
SLACK_USER_MSG_EXPIRATION = 300
|
||||
|
||||
|
||||
class SlackManager(Manager[SlackViewInterface]):
|
||||
class SlackManager(Manager):
|
||||
def __init__(self, token_manager):
|
||||
self.token_manager = token_manager
|
||||
self.login_link = (
|
||||
@@ -79,11 +63,12 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
) -> tuple[SlackUser | None, UserAuth | None]:
|
||||
# We get the user and correlate them back to a user in OpenHands - if we can
|
||||
slack_user = None
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(SlackUser).where(SlackUser.slack_user_id == slack_user_id)
|
||||
with session_maker() as session:
|
||||
slack_user = (
|
||||
session.query(SlackUser)
|
||||
.filter(SlackUser.slack_user_id == slack_user_id)
|
||||
.first()
|
||||
)
|
||||
slack_user = result.scalar_one_or_none()
|
||||
|
||||
# slack_view.slack_to_openhands_user = slack_user # attach user auth info to view
|
||||
|
||||
@@ -96,126 +81,18 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
|
||||
return slack_user, saas_user_auth
|
||||
|
||||
async def _store_user_msg_for_form(
|
||||
self, message_ts: str, thread_ts: str | None, user_msg: str
|
||||
) -> None:
|
||||
"""Store user message in Redis for later retrieval when form is submitted.
|
||||
def _infer_repo_from_message(self, user_msg: str) -> str | None:
|
||||
# Regular expression to match patterns like "OpenHands/OpenHands" or "deploy repo"
|
||||
pattern = r'([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)|([a-zA-Z0-9_-]+)(?=\s+repo)'
|
||||
match = re.search(pattern, user_msg)
|
||||
|
||||
This is needed because when a user selects a repo from the external_select
|
||||
dropdown, Slack sends a separate interaction payload that doesn't include
|
||||
the original user message.
|
||||
if match:
|
||||
repo = match.group(1) if match.group(1) else match.group(2)
|
||||
return repo
|
||||
|
||||
Args:
|
||||
message_ts: The message timestamp (unique identifier)
|
||||
thread_ts: The thread timestamp (if in a thread)
|
||||
user_msg: The original user message to store
|
||||
return None
|
||||
|
||||
Raises:
|
||||
SlackError: If storage fails (REDIS_STORE_FAILED)
|
||||
"""
|
||||
key = f'{SLACK_USER_MSG_KEY_PREFIX}:{message_ts}:{thread_ts}'
|
||||
try:
|
||||
redis = sio.manager.redis
|
||||
await redis.set(key, user_msg, ex=SLACK_USER_MSG_EXPIRATION)
|
||||
logger.info(
|
||||
'slack_stored_user_msg',
|
||||
extra={
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
'key': key,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'slack_store_user_msg_failed',
|
||||
extra={
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
'key': key,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise SlackError(
|
||||
SlackErrorCode.REDIS_STORE_FAILED,
|
||||
log_context={'message_ts': message_ts, 'thread_ts': thread_ts},
|
||||
)
|
||||
|
||||
async def _retrieve_user_msg_for_form(
|
||||
self, message_ts: str, thread_ts: str | None
|
||||
) -> str:
|
||||
"""Retrieve stored user message from Redis.
|
||||
|
||||
Args:
|
||||
message_ts: The message timestamp
|
||||
thread_ts: The thread timestamp (if in a thread)
|
||||
|
||||
Returns:
|
||||
The stored user message
|
||||
|
||||
Raises:
|
||||
SlackError: If retrieval fails (REDIS_RETRIEVE_FAILED) or message
|
||||
not found (SESSION_EXPIRED)
|
||||
"""
|
||||
key = f'{SLACK_USER_MSG_KEY_PREFIX}:{message_ts}:{thread_ts}'
|
||||
try:
|
||||
redis = sio.manager.redis
|
||||
user_msg = await redis.get(key)
|
||||
if user_msg:
|
||||
# Redis returns bytes, decode to string
|
||||
if isinstance(user_msg, bytes):
|
||||
user_msg = user_msg.decode('utf-8')
|
||||
logger.info(
|
||||
'slack_retrieved_user_msg',
|
||||
extra={
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
'key': key,
|
||||
},
|
||||
)
|
||||
return user_msg
|
||||
else:
|
||||
logger.warning(
|
||||
'slack_user_msg_not_found',
|
||||
extra={
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
'key': key,
|
||||
},
|
||||
)
|
||||
raise SlackError(
|
||||
SlackErrorCode.SESSION_EXPIRED,
|
||||
log_context={'message_ts': message_ts, 'thread_ts': thread_ts},
|
||||
)
|
||||
except SlackError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'slack_retrieve_user_msg_failed',
|
||||
extra={
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
'key': key,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise SlackError(
|
||||
SlackErrorCode.REDIS_RETRIEVE_FAILED,
|
||||
log_context={'message_ts': message_ts, 'thread_ts': thread_ts},
|
||||
)
|
||||
|
||||
async def _search_repositories(
|
||||
self, user_auth: UserAuth, query: str = '', per_page: int = 100
|
||||
) -> list[Repository]:
|
||||
"""Search repositories for a user with optional query filtering.
|
||||
|
||||
Args:
|
||||
user_auth: The user's authentication context
|
||||
query: Search query to filter repositories (empty string returns all)
|
||||
per_page: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of matching Repository objects
|
||||
"""
|
||||
async def _get_repositories(self, user_auth: UserAuth) -> list[Repository]:
|
||||
provider_tokens = await user_auth.get_provider_tokens()
|
||||
if provider_tokens is None:
|
||||
return []
|
||||
@@ -226,33 +103,31 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
repos: list[Repository] = await client.search_repositories(
|
||||
selected_provider=None,
|
||||
query=query,
|
||||
per_page=per_page,
|
||||
sort='pushed',
|
||||
order='desc',
|
||||
app_mode=server_config.app_mode,
|
||||
repos: list[Repository] = await client.get_repositories(
|
||||
'pushed', server_config.app_mode, None, None, None, None
|
||||
)
|
||||
return repos
|
||||
|
||||
def _generate_repo_selection_form(
|
||||
self, message_ts: str, thread_ts: str | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Generate a repo selection form using external_select for dynamic loading.
|
||||
self, repo_list: list[Repository], message_ts: str, thread_ts: str | None
|
||||
):
|
||||
options = [
|
||||
{
|
||||
'text': {'type': 'plain_text', 'text': 'No Repository'},
|
||||
'value': '-',
|
||||
}
|
||||
]
|
||||
options.extend(
|
||||
{
|
||||
'text': {
|
||||
'type': 'plain_text',
|
||||
'text': repo.full_name,
|
||||
},
|
||||
'value': repo.full_name,
|
||||
}
|
||||
for repo in repo_list
|
||||
)
|
||||
|
||||
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
|
||||
thread_ts: The thread timestamp if in a thread
|
||||
|
||||
Returns:
|
||||
List of Slack Block Kit blocks for the selection form
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'type': 'header',
|
||||
@@ -262,395 +137,158 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
'emoji': True,
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'section',
|
||||
'text': {
|
||||
'type': 'mrkdwn',
|
||||
'text': 'Type to search your repositories:',
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'actions',
|
||||
'elements': [
|
||||
{
|
||||
'type': 'external_select',
|
||||
'type': 'static_select',
|
||||
'action_id': f'repository_select:{message_ts}:{thread_ts}',
|
||||
'placeholder': {
|
||||
'type': 'plain_text',
|
||||
'text': 'Search repositories...',
|
||||
},
|
||||
'min_query_length': 0, # Load initial options immediately
|
||||
'options': options,
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
def _build_repo_options(self, repos: list[Repository]) -> list[dict[str, Any]]:
|
||||
"""Build Slack options list from repositories.
|
||||
def filter_potential_repos_by_user_msg(
|
||||
self, user_msg: str, user_repos: list[Repository]
|
||||
) -> tuple[bool, list[Repository]]:
|
||||
inferred_repo = self._infer_repo_from_message(user_msg)
|
||||
if not inferred_repo:
|
||||
return False, user_repos[0:99]
|
||||
|
||||
Always includes a "No Repository" option at the top, followed by up to 99
|
||||
repositories (Slack has a 100 option limit for external_select).
|
||||
final_repos = []
|
||||
for repo in user_repos:
|
||||
if inferred_repo.lower() in repo.full_name.lower():
|
||||
final_repos.append(repo)
|
||||
|
||||
Args:
|
||||
repos: List of Repository objects
|
||||
# no repos matched, return original list
|
||||
if len(final_repos) == 0:
|
||||
return False, user_repos[0:99]
|
||||
|
||||
Returns:
|
||||
List of Slack option objects
|
||||
"""
|
||||
options: list[dict[str, Any]] = [
|
||||
{
|
||||
'text': {'type': 'plain_text', 'text': 'No Repository'},
|
||||
'value': '-',
|
||||
}
|
||||
]
|
||||
options.extend(
|
||||
{
|
||||
'text': {
|
||||
'type': 'plain_text',
|
||||
'text': repo.full_name[:75], # Slack has 75 char limit for text
|
||||
},
|
||||
'value': repo.full_name,
|
||||
}
|
||||
for repo in repos[:99] # Leave room for "No Repository" option
|
||||
)
|
||||
return options
|
||||
# Found exact match
|
||||
elif len(final_repos) == 1:
|
||||
return True, final_repos
|
||||
|
||||
async def search_repos_for_slack(
|
||||
self, user_auth: UserAuth, query: str, per_page: int = 20
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Public API for repository search with formatted Slack options.
|
||||
|
||||
This method searches for repositories and formats the results as Slack
|
||||
external_select options.
|
||||
|
||||
Args:
|
||||
user_auth: The user's authentication context
|
||||
query: Search query to filter repositories (empty string returns all)
|
||||
per_page: Maximum number of results to return (default: 20)
|
||||
|
||||
Returns:
|
||||
List of Slack option objects ready for external_select response
|
||||
"""
|
||||
repos = await self._search_repositories(
|
||||
user_auth, query=query, per_page=per_page
|
||||
)
|
||||
return self._build_repo_options(repos)
|
||||
# Found partial matches
|
||||
return False, final_repos[0:99]
|
||||
|
||||
async def receive_message(self, message: Message):
|
||||
"""Process an incoming Slack message.
|
||||
|
||||
This is the single entry point for all Slack message processing.
|
||||
All SlackErrors raised during processing are caught and handled here,
|
||||
sending appropriate error messages to the user.
|
||||
"""
|
||||
self._confirm_incoming_source_type(message)
|
||||
|
||||
try:
|
||||
slack_view = await self._process_message(message)
|
||||
if slack_view and await self.is_job_requested(message, slack_view):
|
||||
await self.start_job(slack_view)
|
||||
|
||||
except SlackError as e:
|
||||
await self.handle_slack_error(message.message, e)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'slack_unexpected_error',
|
||||
extra={'error': str(e), **message.message},
|
||||
)
|
||||
await self.handle_slack_error(
|
||||
message.message,
|
||||
SlackError(SlackErrorCode.UNEXPECTED_ERROR),
|
||||
)
|
||||
|
||||
async def receive_form_interaction(self, slack_payload: dict):
|
||||
"""Process a Slack form interaction (repository selection).
|
||||
|
||||
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
|
||||
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']
|
||||
|
||||
# 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
|
||||
|
||||
# Build partial payload for error handling during Redis retrieval
|
||||
payload = {
|
||||
'team_id': team_id,
|
||||
'channel_id': channel_id,
|
||||
'slack_user_id': slack_user_id,
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
}
|
||||
|
||||
# Retrieve the original user message from Redis
|
||||
try:
|
||||
user_msg = await self._retrieve_user_msg_for_form(message_ts, thread_ts)
|
||||
except SlackError as e:
|
||||
await self.handle_slack_error(payload, e)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'slack_unexpected_error',
|
||||
extra={'error': str(e), **payload},
|
||||
)
|
||||
await self.handle_slack_error(
|
||||
payload, SlackError(SlackErrorCode.UNEXPECTED_ERROR)
|
||||
)
|
||||
return
|
||||
|
||||
# Complete the payload and delegate to receive_message
|
||||
payload['selected_repo'] = selected_repository
|
||||
payload['user_msg'] = user_msg
|
||||
|
||||
message = Message(source=SourceType.SLACK, message=payload)
|
||||
await self.receive_message(message)
|
||||
|
||||
async def _process_message(self, message: Message) -> SlackViewInterface | None:
|
||||
"""Process message and return view if authenticated, or raise SlackError.
|
||||
|
||||
Returns:
|
||||
SlackViewInterface if user is authenticated and ready to proceed,
|
||||
None if processing should stop (but no error).
|
||||
|
||||
Raises:
|
||||
SlackError: If user is not authenticated or other recoverable error.
|
||||
"""
|
||||
slack_user, saas_user_auth = await self.authenticate_user(
|
||||
slack_user_id=message.message['slack_user_id']
|
||||
)
|
||||
|
||||
slack_view = await SlackFactory.create_slack_view_from_payload(
|
||||
message, slack_user, saas_user_auth
|
||||
)
|
||||
|
||||
# Check if this is an unauthenticated user (SlackMessageView but not SlackViewInterface)
|
||||
if not isinstance(slack_view, SlackViewInterface):
|
||||
login_link = self._generate_login_link_with_state(message)
|
||||
raise SlackError(
|
||||
SlackErrorCode.USER_NOT_AUTHENTICATED,
|
||||
message_kwargs={'login_link': login_link},
|
||||
log_context=slack_view.to_log_context(),
|
||||
try:
|
||||
slack_view = SlackFactory.create_slack_view_from_payload(
|
||||
message, slack_user, saas_user_auth
|
||||
)
|
||||
|
||||
return slack_view
|
||||
|
||||
def _generate_login_link_with_state(self, message: Message) -> str:
|
||||
"""Generate OAuth login link with message state encoded."""
|
||||
jwt_secret = config.jwt_secret
|
||||
if not jwt_secret:
|
||||
raise ValueError('Must configure jwt_secret')
|
||||
state = jwt.encode(
|
||||
message.message, jwt_secret.get_secret_value(), algorithm='HS256'
|
||||
)
|
||||
return authorize_url_generator.generate(state)
|
||||
|
||||
async def handle_slack_error(self, payload: dict, error: SlackError) -> None:
|
||||
"""Handle a SlackError by logging and sending user message.
|
||||
|
||||
This is the centralized error handler for all SlackErrors, used by both
|
||||
the manager and routes.
|
||||
|
||||
Args:
|
||||
payload: The Slack payload dict containing channel/user info
|
||||
error: The SlackError to handle
|
||||
"""
|
||||
# Create a minimal view for sending the error message
|
||||
view = await SlackMessageView.from_payload(
|
||||
payload, self._get_slack_team_store()
|
||||
)
|
||||
|
||||
if not view:
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'slack_error_no_view',
|
||||
extra={
|
||||
'error_code': error.code.value,
|
||||
**error.log_context,
|
||||
},
|
||||
f'[Slack]: Failed to create slack view: {e}',
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Log the error
|
||||
log_level = (
|
||||
'exception' if error.code == SlackErrorCode.UNEXPECTED_ERROR else 'warning'
|
||||
)
|
||||
log_data = {
|
||||
'error_code': error.code.value,
|
||||
**view.to_log_context(),
|
||||
**error.log_context,
|
||||
}
|
||||
getattr(logger, log_level)(
|
||||
f'slack_error_{error.code.name.lower()}', extra=log_data
|
||||
)
|
||||
if isinstance(slack_view, SlackUnkownUserView):
|
||||
jwt_secret = config.jwt_secret
|
||||
if not jwt_secret:
|
||||
raise ValueError('Must configure jwt_secret')
|
||||
state = jwt.encode(
|
||||
message.message, jwt_secret.get_secret_value(), algorithm='HS256'
|
||||
)
|
||||
link = authorize_url_generator.generate(state)
|
||||
msg = self.login_link.format(link)
|
||||
|
||||
# Send user-facing message
|
||||
await self.send_message(error.get_user_message(), view, ephemeral=True)
|
||||
logger.info('slack_not_yet_authenticated')
|
||||
await self.send_message(
|
||||
self.create_outgoing_message(msg, ephemeral=True), slack_view
|
||||
)
|
||||
return
|
||||
|
||||
def _get_slack_team_store(self):
|
||||
"""Get the SlackTeamStore instance (lazy import to avoid circular deps)."""
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
if not await self.is_job_requested(message, slack_view):
|
||||
return
|
||||
|
||||
return SlackTeamStore.get_instance()
|
||||
await self.start_job(slack_view)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
message: str | dict[str, Any],
|
||||
slack_view: SlackMessageView,
|
||||
ephemeral: bool = False,
|
||||
):
|
||||
"""Send a message to Slack.
|
||||
|
||||
Args:
|
||||
message: The message content. Can be a string (for simple text) or
|
||||
a dict with 'text' and 'blocks' keys (for structured messages).
|
||||
slack_view: The Slack view object containing channel/thread info.
|
||||
Can be either SlackMessageView (for unauthenticated users)
|
||||
or SlackViewInterface (for authenticated users).
|
||||
ephemeral: If True, send as an ephemeral message visible only to the user.
|
||||
"""
|
||||
async def send_message(self, message: Message, slack_view: SlackViewInterface):
|
||||
client = AsyncWebClient(token=slack_view.bot_access_token)
|
||||
if ephemeral and isinstance(message, str):
|
||||
if message.ephemeral and isinstance(message.message, str):
|
||||
await client.chat_postEphemeral(
|
||||
channel=slack_view.channel_id,
|
||||
markdown_text=message,
|
||||
markdown_text=message.message,
|
||||
user=slack_view.slack_user_id,
|
||||
thread_ts=slack_view.thread_ts,
|
||||
)
|
||||
elif ephemeral and isinstance(message, dict):
|
||||
elif message.ephemeral and isinstance(message.message, dict):
|
||||
await client.chat_postEphemeral(
|
||||
channel=slack_view.channel_id,
|
||||
user=slack_view.slack_user_id,
|
||||
thread_ts=slack_view.thread_ts,
|
||||
text=message['text'],
|
||||
blocks=message['blocks'],
|
||||
text=message.message['text'],
|
||||
blocks=message.message['blocks'],
|
||||
)
|
||||
else:
|
||||
await client.chat_postMessage(
|
||||
channel=slack_view.channel_id,
|
||||
markdown_text=message,
|
||||
markdown_text=message.message,
|
||||
thread_ts=slack_view.message_ts,
|
||||
)
|
||||
|
||||
async def _try_verify_inferred_repo(
|
||||
self, slack_view: SlackNewConversationView
|
||||
) -> bool:
|
||||
"""Try to infer and verify a repository from the user's message.
|
||||
|
||||
Returns:
|
||||
True if a valid repo was found and verified, False otherwise
|
||||
"""
|
||||
user = slack_view.slack_to_openhands_user
|
||||
inferred_repos = infer_repo_from_message(slack_view.user_msg)
|
||||
|
||||
if len(inferred_repos) != 1:
|
||||
return False
|
||||
|
||||
inferred_repo = inferred_repos[0]
|
||||
logger.info(
|
||||
f'[Slack] Verifying inferred repo "{inferred_repo}" '
|
||||
f'for user {user.slack_display_name} (id={slack_view.saas_user_auth.get_user_id()})'
|
||||
)
|
||||
|
||||
try:
|
||||
provider_tokens = await slack_view.saas_user_auth.get_provider_tokens()
|
||||
if not provider_tokens:
|
||||
return False
|
||||
|
||||
access_token = await slack_view.saas_user_auth.get_access_token()
|
||||
user_id = await slack_view.saas_user_auth.get_user_id()
|
||||
provider_handler = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
repo = await provider_handler.verify_repo_provider(inferred_repo)
|
||||
slack_view.selected_repo = repo.full_name
|
||||
return True
|
||||
except (AuthenticationError, ProviderTimeoutError) as e:
|
||||
logger.info(
|
||||
f'[Slack] Could not verify repo "{inferred_repo}": {e}. '
|
||||
f'Showing repository selector.'
|
||||
)
|
||||
return False
|
||||
|
||||
async def _show_repo_selection_form(
|
||||
self, slack_view: SlackNewConversationView
|
||||
) -> None:
|
||||
"""Display the repository selection form to the user.
|
||||
|
||||
Raises:
|
||||
SlackError: If storing the user message fails (REDIS_STORE_FAILED)
|
||||
"""
|
||||
user = slack_view.slack_to_openhands_user
|
||||
logger.info(
|
||||
'render_repository_selector',
|
||||
extra={
|
||||
'slack_user_id': user.slack_user_id,
|
||||
'keycloak_user_id': user.keycloak_user_id,
|
||||
'message_ts': slack_view.message_ts,
|
||||
'thread_ts': slack_view.thread_ts,
|
||||
},
|
||||
)
|
||||
|
||||
# Store the user message for later retrieval - raises SlackError on failure
|
||||
await self._store_user_msg_for_form(
|
||||
slack_view.message_ts, slack_view.thread_ts, slack_view.user_msg
|
||||
)
|
||||
|
||||
repo_selection_msg = {
|
||||
'text': 'Choose a Repository:',
|
||||
'blocks': self._generate_repo_selection_form(
|
||||
slack_view.message_ts, slack_view.thread_ts
|
||||
),
|
||||
}
|
||||
await self.send_message(repo_selection_msg, slack_view, ephemeral=True)
|
||||
|
||||
async def is_job_requested(
|
||||
self, message: Message, slack_view: SlackViewInterface
|
||||
) -> bool:
|
||||
"""Determine if a job should be started based on the current context.
|
||||
|
||||
This method checks:
|
||||
1. If the view type allows immediate job start
|
||||
2. If a repo can be inferred and verified from the message
|
||||
3. Otherwise shows the repo selection form
|
||||
|
||||
Args:
|
||||
slack_view: Must be a SlackViewType (authenticated view that can start jobs)
|
||||
|
||||
Returns:
|
||||
True if job should start, False if waiting for user input
|
||||
"""A job is always request we only receive webhooks for events associated with the slack bot
|
||||
This method really just checks
|
||||
1. Is the user is authenticated
|
||||
2. Do we have the necessary information to start a job (either by inferring the selected repo, otherwise asking the user)
|
||||
"""
|
||||
# Check if view type allows immediate start
|
||||
# Infer repo from user message is not needed; user selected repo from the form or is updating existing convo
|
||||
if isinstance(slack_view, SlackUpdateExistingConversationView):
|
||||
return True
|
||||
if isinstance(slack_view, SlackNewConversationFromRepoFormView):
|
||||
elif isinstance(slack_view, SlackNewConversationFromRepoFormView):
|
||||
return True
|
||||
elif isinstance(slack_view, SlackNewConversationView):
|
||||
user = slack_view.slack_to_openhands_user
|
||||
user_repos: list[Repository] = await self._get_repositories(
|
||||
slack_view.saas_user_auth
|
||||
)
|
||||
match, repos = self.filter_potential_repos_by_user_msg(
|
||||
slack_view.user_msg, user_repos
|
||||
)
|
||||
|
||||
# For new conversations, try to infer/verify repo or show selection form
|
||||
if isinstance(slack_view, SlackNewConversationView):
|
||||
if await self._try_verify_inferred_repo(slack_view):
|
||||
# User mentioned a matching repo is their message, start job without repo selection form
|
||||
if match:
|
||||
slack_view.selected_repo = repos[0].full_name
|
||||
return True
|
||||
await self._show_repo_selection_form(slack_view)
|
||||
|
||||
return False
|
||||
logger.info(
|
||||
'render_repository_selector',
|
||||
extra={
|
||||
'slack_user_id': user,
|
||||
'keycloak_user_id': user.keycloak_user_id,
|
||||
'message_ts': slack_view.message_ts,
|
||||
'thread_ts': slack_view.thread_ts,
|
||||
},
|
||||
)
|
||||
|
||||
async def start_job(self, slack_view: SlackViewInterface) -> None:
|
||||
repo_selection_msg = {
|
||||
'text': 'Choose a Repository:',
|
||||
'blocks': self._generate_repo_selection_form(
|
||||
repos, slack_view.message_ts, slack_view.thread_ts
|
||||
),
|
||||
}
|
||||
await self.send_message(
|
||||
self.create_outgoing_message(repo_selection_msg, ephemeral=True),
|
||||
slack_view,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def start_job(self, slack_view: SlackViewInterface):
|
||||
# Importing here prevents circular import
|
||||
from server.conversation_callback_processor.slack_callback_processor import (
|
||||
SlackCallbackProcessor,
|
||||
@@ -658,7 +296,7 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
|
||||
try:
|
||||
msg_info = None
|
||||
user_info = slack_view.slack_to_openhands_user
|
||||
user_info: SlackUser = slack_view.slack_to_openhands_user
|
||||
try:
|
||||
logger.info(
|
||||
f'[Slack] Starting job for user {user_info.slack_display_name} (id={user_info.slack_user_id})',
|
||||
@@ -730,10 +368,9 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
except StartingConvoException as e:
|
||||
msg_info = str(e)
|
||||
|
||||
await self.send_message(msg_info, slack_view)
|
||||
await self.send_message(self.create_outgoing_message(msg_info), slack_view)
|
||||
|
||||
except Exception:
|
||||
logger.exception('[Slack]: Error starting job')
|
||||
await self.send_message(
|
||||
'Uh oh! There was an unexpected error starting the job :(', slack_view
|
||||
)
|
||||
msg = 'Uh oh! There was an unexpected error starting the job :('
|
||||
await self.send_message(self.create_outgoing_message(msg), slack_view)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
from integrations.types import SummaryExtractionTracker
|
||||
from jinja2 import Environment
|
||||
@@ -8,114 +7,24 @@ from storage.slack_user import SlackUser
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlackMessageView:
|
||||
"""Minimal view for sending messages to Slack.
|
||||
|
||||
This class contains only the fields needed to send messages,
|
||||
without requiring user authentication. Can be used directly for
|
||||
simple message operations or as a base class for more complex views.
|
||||
"""
|
||||
|
||||
class SlackViewInterface(SummaryExtractionTracker, ABC):
|
||||
bot_access_token: str
|
||||
user_msg: str | None
|
||||
slack_user_id: str
|
||||
slack_to_openhands_user: SlackUser | None
|
||||
saas_user_auth: UserAuth | None
|
||||
channel_id: str
|
||||
message_ts: str
|
||||
thread_ts: str | None
|
||||
team_id: str
|
||||
|
||||
def to_log_context(self) -> dict:
|
||||
"""Return dict suitable for structured logging."""
|
||||
return {
|
||||
'slack_channel_id': self.channel_id,
|
||||
'slack_user_id': self.slack_user_id,
|
||||
'slack_team_id': self.team_id,
|
||||
'slack_thread_ts': self.thread_ts,
|
||||
'slack_message_ts': self.message_ts,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def from_payload(
|
||||
cls,
|
||||
payload: dict,
|
||||
slack_team_store,
|
||||
) -> 'SlackMessageView | None':
|
||||
"""Create a view from a raw Slack payload.
|
||||
|
||||
This factory method handles the various payload formats from different
|
||||
Slack interactions (events, form submissions, block suggestions).
|
||||
|
||||
Args:
|
||||
payload: Raw Slack payload dictionary
|
||||
slack_team_store: Store for retrieving bot tokens
|
||||
|
||||
Returns:
|
||||
SlackMessageView if all required fields are available,
|
||||
None if required fields are missing or bot token unavailable.
|
||||
"""
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
team_id = payload.get('team', {}).get('id') or payload.get('team_id')
|
||||
channel_id = (
|
||||
payload.get('container', {}).get('channel_id')
|
||||
or payload.get('channel', {}).get('id')
|
||||
or payload.get('channel_id')
|
||||
)
|
||||
user_id = payload.get('user', {}).get('id') or payload.get('slack_user_id')
|
||||
message_ts = payload.get('message_ts', '')
|
||||
thread_ts = payload.get('thread_ts')
|
||||
|
||||
if not team_id or not channel_id or not user_id:
|
||||
logger.warning(
|
||||
'slack_message_view_from_payload_missing_fields',
|
||||
extra={
|
||||
'has_team_id': bool(team_id),
|
||||
'has_channel_id': bool(channel_id),
|
||||
'has_user_id': bool(user_id),
|
||||
'payload_keys': list(payload.keys()),
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
bot_token = await slack_team_store.get_team_bot_token(team_id)
|
||||
if not bot_token:
|
||||
logger.warning(
|
||||
'slack_message_view_from_payload_no_bot_token',
|
||||
extra={'team_id': team_id},
|
||||
)
|
||||
return None
|
||||
|
||||
return cls(
|
||||
bot_access_token=bot_token,
|
||||
slack_user_id=user_id,
|
||||
channel_id=channel_id,
|
||||
message_ts=message_ts,
|
||||
thread_ts=thread_ts,
|
||||
team_id=team_id,
|
||||
)
|
||||
|
||||
|
||||
class SlackViewInterface(SlackMessageView, SummaryExtractionTracker, ABC):
|
||||
"""Interface for authenticated Slack views that can create conversations.
|
||||
|
||||
All fields are required (non-None) because this interface is only used
|
||||
for users who have linked their Slack account to OpenHands.
|
||||
|
||||
Inherits from SlackMessageView:
|
||||
bot_access_token, slack_user_id, channel_id, message_ts, thread_ts, team_id
|
||||
"""
|
||||
|
||||
user_msg: str
|
||||
slack_to_openhands_user: SlackUser
|
||||
saas_user_auth: UserAuth
|
||||
selected_repo: str | None
|
||||
should_extract: bool
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
team_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
@abstractmethod
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
pass
|
||||
|
||||
@@ -130,4 +39,4 @@ class SlackViewInterface(SlackMessageView, SummaryExtractionTracker, ABC):
|
||||
|
||||
|
||||
class StartingConvoException(Exception):
|
||||
"""Raised when trying to send message to a conversation that is still starting up."""
|
||||
"""Raised when trying to send message to a conversation that's is still starting up"""
|
||||
|
||||
@@ -2,8 +2,7 @@ import logging
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from integrations.utils import get_summary_instruction
|
||||
from integrations.v1_utils import handle_callback_error
|
||||
from integrations.utils import CONVERSATION_URL, get_summary_instruction
|
||||
from pydantic import Field
|
||||
from slack_sdk import WebClient
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
@@ -40,6 +39,7 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for Slack V1 integration."""
|
||||
|
||||
# Only handle ConversationStateUpdateEvent
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
@@ -62,14 +62,19 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
|
||||
detail=summary,
|
||||
)
|
||||
except Exception as e:
|
||||
await handle_callback_error(
|
||||
error=e,
|
||||
conversation_id=conversation_id,
|
||||
service_name='Slack',
|
||||
service_logger=_logger,
|
||||
can_post_error=True, # Slack always attempts to post errors
|
||||
post_error_func=self._post_summary_to_slack,
|
||||
)
|
||||
_logger.exception('[Slack V1] Error processing callback: %s', e)
|
||||
|
||||
# Only try to post error to Slack if we have basic requirements
|
||||
try:
|
||||
await self._post_summary_to_slack(
|
||||
f'OpenHands encountered an error: **{str(e)}**.\n\n'
|
||||
f'[See the conversation]({CONVERSATION_URL.format(conversation_id)})'
|
||||
'for more information.'
|
||||
)
|
||||
except Exception as post_error:
|
||||
_logger.warning(
|
||||
'[Slack V1] Failed to post error message to Slack: %s', post_error
|
||||
)
|
||||
|
||||
return EventCallbackResult(
|
||||
status=EventCallbackResultStatus.ERROR,
|
||||
@@ -83,18 +88,17 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
|
||||
# Slack helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _get_bot_access_token(self) -> str | None:
|
||||
team_id = self.slack_view_data.get('team_id')
|
||||
if team_id is None:
|
||||
return None
|
||||
def _get_bot_access_token(self):
|
||||
slack_team_store = SlackTeamStore.get_instance()
|
||||
bot_access_token = await slack_team_store.get_team_bot_token(team_id)
|
||||
bot_access_token = slack_team_store.get_team_bot_token(
|
||||
self.slack_view_data['team_id']
|
||||
)
|
||||
|
||||
return bot_access_token
|
||||
|
||||
async def _post_summary_to_slack(self, summary: str) -> None:
|
||||
"""Post a summary message to the configured Slack channel."""
|
||||
bot_access_token = await self._get_bot_access_token()
|
||||
bot_access_token = self._get_bot_access_token()
|
||||
if not bot_access_token:
|
||||
raise RuntimeError('Missing Slack bot access token')
|
||||
|
||||
@@ -144,8 +148,8 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
|
||||
send_message_request = AskAgentRequest(question=message_content)
|
||||
|
||||
url = (
|
||||
f"{agent_server_url.rstrip('/')}"
|
||||
f"/api/conversations/{conversation_id}/ask_agent"
|
||||
f'{agent_server_url.rstrip("/")}'
|
||||
f'/api/conversations/{conversation_id}/ask_agent'
|
||||
)
|
||||
headers = {'X-Session-API-Key': session_api_key}
|
||||
payload = send_message_request.model_dump()
|
||||
@@ -207,7 +211,8 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _request_summary(self, conversation_id: UUID) -> str:
|
||||
"""Ask the agent to produce a summary of its work and return the agent response.
|
||||
"""
|
||||
Ask the agent to produce a summary of its work and return the agent response.
|
||||
|
||||
NOTE: This method now returns a string (the agent server's response text)
|
||||
and raises exceptions on errors. The wrapping into EventCallbackResult
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.slack.slack_types import (
|
||||
SlackMessageView,
|
||||
SlackViewInterface,
|
||||
StartingConvoException,
|
||||
)
|
||||
from integrations.slack.slack_types import SlackViewInterface, StartingConvoException
|
||||
from integrations.slack.slack_v1_callback_processor import SlackV1CallbackProcessor
|
||||
from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
@@ -47,7 +42,7 @@ from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
|
||||
|
||||
# =================================================
|
||||
# SECTION: Slack view types
|
||||
@@ -63,10 +58,37 @@ async def is_v1_enabled_for_slack_resolver(user_id: str) -> bool:
|
||||
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_SLACK_RESOLVER
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlackUnkownUserView(SlackViewInterface):
|
||||
bot_access_token: str
|
||||
user_msg: str | None
|
||||
slack_user_id: str
|
||||
slack_to_openhands_user: SlackUser | None
|
||||
saas_user_auth: UserAuth | None
|
||||
channel_id: str
|
||||
message_ts: str
|
||||
thread_ts: str | None
|
||||
selected_repo: str | None
|
||||
should_extract: bool
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
team_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlackNewConversationView(SlackViewInterface):
|
||||
bot_access_token: str
|
||||
user_msg: str
|
||||
user_msg: str | None
|
||||
slack_user_id: str
|
||||
slack_to_openhands_user: SlackUser
|
||||
saas_user_auth: UserAuth
|
||||
@@ -96,7 +118,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
return block['user_id']
|
||||
return ''
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
|
||||
@@ -220,9 +242,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
self, jinja: Environment, provider_tokens, user_secrets
|
||||
) -> None:
|
||||
"""Create conversation using the legacy V0 system."""
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja
|
||||
)
|
||||
user_instructions, conversation_instructions = self._get_instructions(jinja)
|
||||
|
||||
# Determine git provider from repository
|
||||
git_provider = None
|
||||
@@ -253,9 +273,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
|
||||
async def _create_v1_conversation(self, jinja: Environment) -> None:
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja
|
||||
)
|
||||
user_instructions, conversation_instructions = self._get_instructions(jinja)
|
||||
|
||||
# Create the initial message request
|
||||
initial_message = SendMessageRequest(
|
||||
@@ -328,7 +346,7 @@ class SlackNewConversationFromRepoFormView(SlackNewConversationView):
|
||||
class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
slack_conversation: SlackConversation
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
client = WebClient(token=self.bot_access_token)
|
||||
result = client.conversations_replies(
|
||||
channel=self.channel_id,
|
||||
@@ -371,9 +389,6 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
self.conversation_id, conversation_init_data, user_id
|
||||
)
|
||||
|
||||
if agent_loop_info.event_store is None:
|
||||
raise StartingConvoException('Event store not available')
|
||||
|
||||
final_agent_observation = get_final_agent_observation(
|
||||
agent_loop_info.event_store
|
||||
)
|
||||
@@ -386,7 +401,7 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
if not agent_state or agent_state == AgentState.LOADING:
|
||||
raise StartingConvoException('Conversation is still starting')
|
||||
|
||||
instructions, _ = await self._get_instructions(jinja)
|
||||
instructions, _ = self._get_instructions(jinja)
|
||||
user_msg = MessageAction(content=instructions)
|
||||
await conversation_manager.send_event_to_conversation(
|
||||
self.conversation_id, event_to_dict(user_msg)
|
||||
@@ -454,7 +469,7 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
agent_server_url = get_agent_server_url_from_sandbox(running_sandbox)
|
||||
|
||||
# 4. Prepare the message content
|
||||
user_msg, _ = await self._get_instructions(jinja)
|
||||
user_msg, _ = self._get_instructions(jinja)
|
||||
|
||||
# 5. Create the message request
|
||||
send_message_request = SendMessageRequest(
|
||||
@@ -462,7 +477,7 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
)
|
||||
|
||||
# 6. Send the message to the agent server
|
||||
url = f"{agent_server_url.rstrip('/')}/api/conversations/{UUID(self.conversation_id)}/events"
|
||||
url = f'{agent_server_url.rstrip("/")}/api/conversations/{UUID(self.conversation_id)}/events'
|
||||
|
||||
headers = {'X-Session-API-Key': running_sandbox.session_api_key}
|
||||
payload = send_message_request.model_dump()
|
||||
@@ -530,14 +545,11 @@ class SlackFactory:
|
||||
return None
|
||||
|
||||
# thread_ts in slack payloads in the parent's (root level msg's) message ID
|
||||
if channel_id is None:
|
||||
return None
|
||||
return await slack_conversation_store.get_slack_conversation(
|
||||
channel_id, thread_ts
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def create_slack_view_from_payload(
|
||||
def create_slack_view_from_payload(
|
||||
message: Message, slack_user: SlackUser | None, saas_user_auth: UserAuth | None
|
||||
):
|
||||
payload = message.message
|
||||
@@ -548,7 +560,7 @@ class SlackFactory:
|
||||
team_id = payload['team_id']
|
||||
user_msg = payload.get('user_msg')
|
||||
|
||||
bot_access_token = await slack_team_store.get_team_bot_token(team_id)
|
||||
bot_access_token = slack_team_store.get_team_bot_token(team_id)
|
||||
if not bot_access_token:
|
||||
logger.error(
|
||||
'Did not find slack team',
|
||||
@@ -560,27 +572,28 @@ class SlackFactory:
|
||||
raise Exception('Did not find slack team')
|
||||
|
||||
# Determine if this is a known slack user by openhands
|
||||
# Return SlackMessageView (not SlackViewInterface) for unauthenticated users
|
||||
if not slack_user or not saas_user_auth or not channel_id or not message_ts:
|
||||
return SlackMessageView(
|
||||
if not slack_user or not saas_user_auth or not channel_id:
|
||||
return SlackUnkownUserView(
|
||||
bot_access_token=bot_access_token,
|
||||
user_msg=user_msg,
|
||||
slack_user_id=slack_user_id,
|
||||
channel_id=channel_id or '',
|
||||
message_ts=message_ts or '',
|
||||
slack_to_openhands_user=slack_user,
|
||||
saas_user_auth=saas_user_auth,
|
||||
channel_id=channel_id,
|
||||
message_ts=message_ts,
|
||||
thread_ts=thread_ts,
|
||||
selected_repo=None,
|
||||
should_extract=False,
|
||||
send_summary_instruction=False,
|
||||
conversation_id='',
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
# At this point, we've verified slack_user, saas_user_auth, channel_id, and message_ts are set
|
||||
# user_msg should always be present in Slack payloads
|
||||
if not user_msg:
|
||||
raise ValueError('user_msg is required but was not provided in payload')
|
||||
assert channel_id is not None
|
||||
assert message_ts is not None
|
||||
|
||||
conversation = await asyncio.wait_for(
|
||||
SlackFactory.determine_if_updating_existing_conversation(message),
|
||||
timeout=GENERAL_TIMEOUT,
|
||||
conversation: SlackConversation | None = call_async_from_sync(
|
||||
SlackFactory.determine_if_updating_existing_conversation,
|
||||
GENERAL_TIMEOUT,
|
||||
message,
|
||||
)
|
||||
if conversation:
|
||||
logger.info(
|
||||
@@ -643,11 +656,3 @@ class SlackFactory:
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
# Type alias for all authenticated Slack view types that can start conversations
|
||||
SlackViewType = (
|
||||
SlackNewConversationView
|
||||
| SlackNewConversationFromRepoFormView
|
||||
| SlackUpdateExistingConversationView
|
||||
)
|
||||
|
||||
@@ -42,11 +42,11 @@ async def store_repositories_in_db(repos: list[Repository], user_id: str) -> Non
|
||||
try:
|
||||
# Store repositories in the repos table
|
||||
repo_store = RepositoryStore.get_instance(config)
|
||||
await repo_store.store_projects(stored_repos)
|
||||
repo_store.store_projects(stored_repos)
|
||||
|
||||
# Store user-repository mappings in the user-repos table
|
||||
user_repo_store = UserRepositoryMapStore.get_instance(config)
|
||||
await user_repo_store.store_user_repo_mappings(user_repos)
|
||||
user_repo_store.store_user_repo_mappings(user_repos)
|
||||
|
||||
logger.info(f'Saved repos for user {user_id}')
|
||||
except Exception:
|
||||
|
||||
@@ -3,20 +3,24 @@ from uuid import UUID
|
||||
import stripe
|
||||
from server.constants import STRIPE_API_KEY
|
||||
from server.logger import logger
|
||||
from sqlalchemy import select
|
||||
from storage.database import a_session_maker
|
||||
from sqlalchemy.orm import Session
|
||||
from storage.database import session_maker
|
||||
from storage.org import Org
|
||||
from storage.org_store import OrgStore
|
||||
from storage.stripe_customer import StripeCustomer
|
||||
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
stripe.api_key = STRIPE_API_KEY
|
||||
|
||||
|
||||
async def find_customer_id_by_org_id(org_id: UUID) -> str | None:
|
||||
async with a_session_maker() as session:
|
||||
stmt = select(StripeCustomer).where(StripeCustomer.org_id == org_id)
|
||||
result = await session.execute(stmt)
|
||||
stripe_customer = result.scalar_one_or_none()
|
||||
with session_maker() as session:
|
||||
stripe_customer = (
|
||||
session.query(StripeCustomer)
|
||||
.filter(StripeCustomer.org_id == org_id)
|
||||
.first()
|
||||
)
|
||||
if stripe_customer:
|
||||
return stripe_customer.stripe_customer_id
|
||||
|
||||
@@ -36,7 +40,9 @@ async def find_customer_id_by_org_id(org_id: UUID) -> str | None:
|
||||
|
||||
async def find_customer_id_by_user_id(user_id: str) -> str | None:
|
||||
# First search our own DB...
|
||||
org = await OrgStore.get_current_org_from_keycloak_user_id(user_id)
|
||||
org = await call_sync_from_async(
|
||||
OrgStore.get_current_org_from_keycloak_user_id, user_id
|
||||
)
|
||||
if not org:
|
||||
logger.warning(f'Org not found for user {user_id}')
|
||||
return None
|
||||
@@ -46,7 +52,9 @@ async def find_customer_id_by_user_id(user_id: str) -> str | None:
|
||||
|
||||
async def find_or_create_customer_by_user_id(user_id: str) -> dict | None:
|
||||
# Get the current org for the user
|
||||
org = await OrgStore.get_current_org_from_keycloak_user_id(user_id)
|
||||
org = await call_sync_from_async(
|
||||
OrgStore.get_current_org_from_keycloak_user_id, user_id
|
||||
)
|
||||
if not org:
|
||||
logger.warning(f'Org not found for user {user_id}')
|
||||
return None
|
||||
@@ -66,7 +74,7 @@ async def find_or_create_customer_by_user_id(user_id: str) -> dict | None:
|
||||
)
|
||||
|
||||
# Save the stripe customer in the local db
|
||||
async with a_session_maker() as session:
|
||||
with session_maker() as session:
|
||||
session.add(
|
||||
StripeCustomer(
|
||||
keycloak_user_id=user_id,
|
||||
@@ -74,7 +82,7 @@ async def find_or_create_customer_by_user_id(user_id: str) -> dict | None:
|
||||
stripe_customer_id=customer.id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
'created_customer',
|
||||
@@ -100,27 +108,26 @@ async def has_payment_method_by_user_id(user_id: str) -> bool:
|
||||
return bool(payment_methods.data)
|
||||
|
||||
|
||||
async def migrate_customer(user_id: str, org: Org):
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
|
||||
)
|
||||
stripe_customer = result.scalar_one_or_none()
|
||||
if stripe_customer is None:
|
||||
return
|
||||
stripe_customer.org_id = org.id
|
||||
customer = await stripe.Customer.modify_async(
|
||||
id=stripe_customer.stripe_customer_id,
|
||||
email=org.contact_email,
|
||||
metadata={'user_id': '', 'org_id': str(org.id)},
|
||||
)
|
||||
async def migrate_customer(session: Session, user_id: str, org: Org):
|
||||
stripe_customer = (
|
||||
session.query(StripeCustomer)
|
||||
.filter(StripeCustomer.keycloak_user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if stripe_customer is None:
|
||||
return
|
||||
stripe_customer.org_id = org.id
|
||||
customer = await stripe.Customer.modify_async(
|
||||
id=stripe_customer.stripe_customer_id,
|
||||
email=org.contact_email,
|
||||
metadata={'user_id': '', 'org_id': str(org.id)},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'migrated_customer',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org.id),
|
||||
'stripe_customer_id': customer.id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
'migrated_customer',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org.id),
|
||||
'stripe_customer_id': customer.id,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from jinja2 import Environment
|
||||
from pydantic import BaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from integrations.models import Message
|
||||
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
|
||||
|
||||
class GitLabResourceType(Enum):
|
||||
GROUP = 'group'
|
||||
@@ -39,41 +31,17 @@ class SummaryExtractionTracker:
|
||||
|
||||
@dataclass
|
||||
class ResolverViewInterface(SummaryExtractionTracker):
|
||||
# installation_id type varies by provider:
|
||||
# - GitHub: int (GitHub App installation ID)
|
||||
# - GitLab: str (webhook installation ID from our DB)
|
||||
installation_id: int | str
|
||||
installation_id: int
|
||||
user_info: UserData
|
||||
issue_number: int
|
||||
full_repo_name: str
|
||||
is_public_repo: bool
|
||||
raw_payload: 'Message'
|
||||
raw_payload: dict
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized."""
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"Instructions passed when conversation is first initialized"
|
||||
raise NotImplementedError()
|
||||
|
||||
async def initialize_new_conversation(self) -> 'ConversationMetadata':
|
||||
"""Initialize a new conversation and return metadata.
|
||||
|
||||
For V1 conversations, creates a dummy ConversationMetadata.
|
||||
For V0 conversations, initializes through the conversation store.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def create_new_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: 'PROVIDER_TOKEN_TYPE',
|
||||
conversation_metadata: 'ConversationMetadata',
|
||||
saas_user_auth: 'UserAuth',
|
||||
) -> None:
|
||||
"""Create a new conversation.
|
||||
|
||||
Args:
|
||||
jinja_env: Jinja2 environment for template rendering
|
||||
git_provider_tokens: Token mapping for git providers
|
||||
conversation_metadata: Metadata for the conversation
|
||||
saas_user_auth: User authentication for SaaS
|
||||
"""
|
||||
async def create_new_conversation(self, jinja_env: Environment, token: str):
|
||||
"Create a new conversation"
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -21,6 +21,7 @@ from openhands.events.event_store_abc import EventStoreABC
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.server.conversation_manager.conversation_manager import (
|
||||
@@ -32,8 +33,7 @@ if TYPE_CHECKING:
|
||||
HOST = WEB_HOST
|
||||
# ---- DO NOT REMOVE ----
|
||||
|
||||
IS_LOCAL_DEPLOYMENT = 'localhost' in HOST
|
||||
HOST_URL = f'https://{HOST}' if not IS_LOCAL_DEPLOYMENT else f'http://{HOST}'
|
||||
HOST_URL = f'https://{HOST}' if 'localhost' not in HOST else f'http://{HOST}'
|
||||
GITHUB_WEBHOOK_URL = f'{HOST_URL}/integration/github/events'
|
||||
GITLAB_WEBHOOK_URL = f'{HOST_URL}/integration/gitlab/events'
|
||||
conversation_prefix = 'conversations/{}'
|
||||
@@ -65,25 +65,6 @@ def get_session_expired_message(username: str | None = None) -> str:
|
||||
return f'Your session has expired. Please login again at [OpenHands Cloud]({HOST_URL}) and try again.'
|
||||
|
||||
|
||||
def get_user_not_found_message(username: str | None = None) -> str:
|
||||
"""Get a user-friendly message when a user hasn't created an OpenHands account.
|
||||
|
||||
Used by integrations to notify users when they try to use OpenHands features
|
||||
but haven't logged into OpenHands Cloud yet (no Keycloak account exists).
|
||||
|
||||
Args:
|
||||
username: Optional username to mention in the message. If provided,
|
||||
the message will include @username prefix (used by Git providers
|
||||
like GitHub, GitLab, Slack). If None, returns a generic message.
|
||||
|
||||
Returns:
|
||||
A formatted user not found message
|
||||
"""
|
||||
if username:
|
||||
return f"@{username} it looks like you haven't created an OpenHands account yet. Please sign up at [OpenHands Cloud]({HOST_URL}) and try again."
|
||||
return f"It looks like you haven't created an OpenHands account yet. Please sign up at [OpenHands Cloud]({HOST_URL}) and try again."
|
||||
|
||||
|
||||
# Toggle for solvability report feature
|
||||
ENABLE_SOLVABILITY_ANALYSIS = (
|
||||
os.getenv('ENABLE_SOLVABILITY_ANALYSIS', 'false').lower() == 'true'
|
||||
@@ -98,11 +79,6 @@ ENABLE_V1_SLACK_RESOLVER = (
|
||||
os.getenv('ENABLE_V1_SLACK_RESOLVER', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
# Toggle for V1 GitLab resolver feature
|
||||
ENABLE_V1_GITLAB_RESOLVER = (
|
||||
os.getenv('ENABLE_V1_GITLAB_RESOLVER', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR = (
|
||||
os.getenv('OPENHANDS_RESOLVER_TEMPLATES_DIR')
|
||||
or 'openhands/integrations/templates/resolver/'
|
||||
@@ -146,7 +122,9 @@ async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
org = await OrgStore.get_current_org_from_keycloak_user_id(user_id)
|
||||
org = await call_sync_from_async(
|
||||
OrgStore.get_current_org_from_keycloak_user_id, user_id
|
||||
)
|
||||
|
||||
if not org or org.v1_enabled is None:
|
||||
return False
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import logging
|
||||
from typing import Callable, Coroutine
|
||||
from uuid import UUID
|
||||
|
||||
from integrations.utils import CONVERSATION_URL
|
||||
from pydantic import SecretStr
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
@@ -11,78 +6,6 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
def is_budget_exceeded_error(error_message: str) -> bool:
|
||||
"""Check if an error message indicates a budget exceeded condition.
|
||||
|
||||
This is used to downgrade error logs to info logs for budget exceeded errors
|
||||
since they are expected cost control behavior rather than unexpected errors.
|
||||
"""
|
||||
lower_message = error_message.lower()
|
||||
return 'budget' in lower_message and 'exceeded' in lower_message
|
||||
|
||||
|
||||
BUDGET_EXCEEDED_USER_MESSAGE = 'LLM budget has been exceeded, please re-fill.'
|
||||
|
||||
|
||||
async def handle_callback_error(
|
||||
error: Exception,
|
||||
conversation_id: UUID,
|
||||
service_name: str,
|
||||
service_logger: logging.Logger,
|
||||
can_post_error: bool,
|
||||
post_error_func: Callable[[str], Coroutine],
|
||||
) -> None:
|
||||
"""Handle callback processing errors with appropriate logging and user messages.
|
||||
|
||||
This centralizes the error handling logic for V1 callback processors to:
|
||||
- Log budget exceeded errors at INFO level (expected cost control behavior)
|
||||
- Log other errors at EXCEPTION level
|
||||
- Post user-friendly error messages to the integration platform
|
||||
|
||||
Args:
|
||||
error: The exception that occurred
|
||||
conversation_id: The conversation ID for logging and linking
|
||||
service_name: The service name for log messages (e.g., "GitHub", "GitLab", "Slack")
|
||||
service_logger: The logger instance to use for logging
|
||||
can_post_error: Whether the prerequisites are met to post an error message
|
||||
post_error_func: Async function to post the error message to the platform
|
||||
"""
|
||||
error_str = str(error)
|
||||
budget_exceeded = is_budget_exceeded_error(error_str)
|
||||
|
||||
# Log appropriately based on error type
|
||||
if budget_exceeded:
|
||||
service_logger.info(
|
||||
'[%s V1] Budget exceeded for conversation %s: %s',
|
||||
service_name,
|
||||
conversation_id,
|
||||
error,
|
||||
)
|
||||
else:
|
||||
service_logger.exception(
|
||||
'[%s V1] Error processing callback: %s', service_name, error
|
||||
)
|
||||
|
||||
# Try to post error message to the platform
|
||||
if can_post_error:
|
||||
try:
|
||||
error_detail = (
|
||||
BUDGET_EXCEEDED_USER_MESSAGE if budget_exceeded else error_str
|
||||
)
|
||||
await post_error_func(
|
||||
f'OpenHands encountered an error: **{error_detail}**\n\n'
|
||||
f'[See the conversation]({CONVERSATION_URL.format(conversation_id)}) '
|
||||
'for more information.'
|
||||
)
|
||||
except Exception as post_error:
|
||||
service_logger.warning(
|
||||
'[%s V1] Failed to post error message to %s: %s',
|
||||
service_name,
|
||||
service_name,
|
||||
post_error,
|
||||
)
|
||||
|
||||
|
||||
async def get_saas_user_auth(
|
||||
keycloak_user_id: str, token_manager: TokenManager
|
||||
) -> UserAuth:
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"""Create user_authorizations table and migrate blocked_email_domains
|
||||
|
||||
Revision ID: 099
|
||||
Revises: 098
|
||||
Create Date: 2025-03-05 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '099'
|
||||
down_revision: Union[str, None] = '098'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _seed_from_environment() -> None:
|
||||
"""Seed user_authorizations table from environment variables.
|
||||
|
||||
Reads EMAIL_PATTERN_BLACKLIST and EMAIL_PATTERN_WHITELIST environment variables.
|
||||
Each should be a comma-separated list of SQL LIKE patterns (e.g., '%@example.com').
|
||||
|
||||
If the environment variables are not set or empty, this function does nothing.
|
||||
|
||||
This allows us to set up feature deployments with particular patterns already
|
||||
blacklisted or whitelisted. (For example, you could blacklist everything with
|
||||
`%`, and then whitelist certain email accounts.)
|
||||
"""
|
||||
blacklist_patterns = os.environ.get('EMAIL_PATTERN_BLACKLIST', '').strip()
|
||||
whitelist_patterns = os.environ.get('EMAIL_PATTERN_WHITELIST', '').strip()
|
||||
|
||||
connection = op.get_bind()
|
||||
|
||||
if blacklist_patterns:
|
||||
for pattern in blacklist_patterns.split(','):
|
||||
pattern = pattern.strip()
|
||||
if pattern:
|
||||
connection.execute(
|
||||
sa.text("""
|
||||
INSERT INTO user_authorizations
|
||||
(email_pattern, provider_type, type)
|
||||
VALUES
|
||||
(:pattern, NULL, 'blacklist')
|
||||
"""),
|
||||
{'pattern': pattern},
|
||||
)
|
||||
|
||||
if whitelist_patterns:
|
||||
for pattern in whitelist_patterns.split(','):
|
||||
pattern = pattern.strip()
|
||||
if pattern:
|
||||
connection.execute(
|
||||
sa.text("""
|
||||
INSERT INTO user_authorizations
|
||||
(email_pattern, provider_type, type)
|
||||
VALUES
|
||||
(:pattern, NULL, 'whitelist')
|
||||
"""),
|
||||
{'pattern': pattern},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create user_authorizations table, migrate data, and drop blocked_email_domains."""
|
||||
# Create user_authorizations table
|
||||
op.create_table(
|
||||
'user_authorizations',
|
||||
sa.Column('id', sa.Integer(), sa.Identity(), nullable=False, primary_key=True),
|
||||
sa.Column('email_pattern', sa.String(), nullable=True),
|
||||
sa.Column('provider_type', sa.String(), nullable=True),
|
||||
sa.Column('type', sa.String(), nullable=False),
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||
),
|
||||
sa.Column(
|
||||
'updated_at',
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
# Create index on email_pattern for efficient LIKE queries
|
||||
op.create_index(
|
||||
'ix_user_authorizations_email_pattern',
|
||||
'user_authorizations',
|
||||
['email_pattern'],
|
||||
)
|
||||
|
||||
# Create index on type for efficient filtering
|
||||
op.create_index(
|
||||
'ix_user_authorizations_type',
|
||||
'user_authorizations',
|
||||
['type'],
|
||||
)
|
||||
|
||||
# Migrate existing blocked_email_domains to user_authorizations as blacklist entries
|
||||
# The domain patterns are converted to SQL LIKE patterns:
|
||||
# - 'example.com' becomes '%@example.com' (matches user@example.com)
|
||||
# - '.us' becomes '%@%.us' (matches user@anything.us)
|
||||
# We also add '%.' prefix for subdomain matching
|
||||
op.execute("""
|
||||
INSERT INTO user_authorizations (email_pattern, provider_type, type, created_at, updated_at)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN domain LIKE '.%' THEN '%' || domain
|
||||
ELSE '%@%' || domain
|
||||
END as email_pattern,
|
||||
NULL as provider_type,
|
||||
'blacklist' as type,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM blocked_email_domains
|
||||
""")
|
||||
|
||||
# Seed additional patterns from environment variables (if set)
|
||||
_seed_from_environment()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Recreate blocked_email_domains table and migrate data back."""
|
||||
# Drop user_authorizations table
|
||||
op.drop_index('ix_user_authorizations_type', table_name='user_authorizations')
|
||||
op.drop_index(
|
||||
'ix_user_authorizations_email_pattern', table_name='user_authorizations'
|
||||
)
|
||||
op.drop_table('user_authorizations')
|
||||
754
enterprise/poetry.lock
generated
754
enterprise/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ packages = [
|
||||
{ include = "storage" },
|
||||
{ include = "sync" },
|
||||
{ include = "integrations" },
|
||||
{ include = "experiments" },
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
@@ -48,7 +49,7 @@ prometheus-client = "^0.24.0"
|
||||
pandas = "^2.2.0"
|
||||
numpy = "^2.2.0"
|
||||
mcp = "^1.10.0"
|
||||
pillow = "^12.1.1"
|
||||
pillow = "^12.1.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.8.3"
|
||||
|
||||
@@ -21,12 +21,11 @@ async def main():
|
||||
|
||||
|
||||
def set_stale_task_error():
|
||||
# started_at is naive UTC; strip tzinfo before comparing.
|
||||
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=1)
|
||||
with session_maker() as session:
|
||||
session.query(MaintenanceTask).filter(
|
||||
MaintenanceTask.status == MaintenanceTaskStatus.WORKING,
|
||||
MaintenanceTask.started_at < cutoff,
|
||||
MaintenanceTask.started_at
|
||||
< datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
).update({MaintenanceTask.status: MaintenanceTaskStatus.ERROR})
|
||||
session.commit()
|
||||
|
||||
@@ -38,10 +37,9 @@ async def run_tasks():
|
||||
if not task:
|
||||
return
|
||||
|
||||
# started_at/updated_at are naive UTC; strip tzinfo.
|
||||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
# Update the status
|
||||
task.status = MaintenanceTaskStatus.WORKING
|
||||
task.updated_at = task.started_at = now_utc
|
||||
task.updated_at = task.started_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
|
||||
try:
|
||||
|
||||
@@ -14,7 +14,6 @@ from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
||||
from fastapi.responses import JSONResponse # noqa: E402
|
||||
from server.auth.auth_error import ExpiredError, NoCredentialsError # noqa: E402
|
||||
from server.auth.constants import ( # noqa: E402
|
||||
BITBUCKET_DATA_CENTER_HOST,
|
||||
ENABLE_JIRA,
|
||||
ENABLE_JIRA_DC,
|
||||
ENABLE_LINEAR,
|
||||
@@ -28,6 +27,7 @@ from server.rate_limit import setup_rate_limit_handler # noqa: E402
|
||||
from server.routes.api_keys import api_router as api_keys_router # noqa: E402
|
||||
from server.routes.auth import api_router, oauth_router # noqa: E402
|
||||
from server.routes.billing import billing_router # noqa: E402
|
||||
from server.routes.debugging import add_debugging_routes # noqa: E402
|
||||
from server.routes.email import api_router as email_router # noqa: E402
|
||||
from server.routes.event_webhook import event_webhook_router # noqa: E402
|
||||
from server.routes.feedback import router as feedback_router # noqa: E402
|
||||
@@ -124,6 +124,9 @@ override_llm_models_dependency(base_app)
|
||||
base_app.include_router(invitation_router) # Add routes for org invitation management
|
||||
base_app.include_router(invitation_accept_router) # Add route for accepting invitations
|
||||
add_github_proxy_routes(base_app)
|
||||
add_debugging_routes(
|
||||
base_app
|
||||
) # Add diagnostic routes for testing and debugging (disabled in production)
|
||||
base_app.include_router(slack_router)
|
||||
if ENABLE_JIRA:
|
||||
base_app.include_router(jira_integration_router)
|
||||
@@ -131,12 +134,6 @@ if ENABLE_JIRA_DC:
|
||||
base_app.include_router(jira_dc_integration_router)
|
||||
if ENABLE_LINEAR:
|
||||
base_app.include_router(linear_integration_router)
|
||||
if BITBUCKET_DATA_CENTER_HOST:
|
||||
from server.routes.bitbucket_dc_proxy import (
|
||||
router as bitbucket_dc_proxy_router, # noqa: E402
|
||||
)
|
||||
|
||||
base_app.include_router(bitbucket_dc_proxy_router)
|
||||
base_app.include_router(email_router) # Add routes for email management
|
||||
base_app.include_router(feedback_router) # Add routes for conversation feedback
|
||||
base_app.include_router(
|
||||
|
||||
53
enterprise/server/auth/auth_utils.py
Normal file
53
enterprise/server/auth/auth_utils.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class UserVerifier:
|
||||
def __init__(self) -> None:
|
||||
logger.debug('Initializing UserVerifier')
|
||||
self.file_users: list[str] | None = None
|
||||
|
||||
# Initialize from environment variables
|
||||
self._init_file_users()
|
||||
|
||||
def _init_file_users(self) -> None:
|
||||
"""Load users from text file if configured."""
|
||||
waitlist = os.getenv('GITHUB_USER_LIST_FILE')
|
||||
if not waitlist:
|
||||
logger.debug('GITHUB_USER_LIST_FILE not configured')
|
||||
return
|
||||
|
||||
if not os.path.exists(waitlist):
|
||||
logger.error(f'User list file not found: {waitlist}')
|
||||
raise FileNotFoundError(f'User list file not found: {waitlist}')
|
||||
|
||||
try:
|
||||
with open(waitlist, 'r') as f:
|
||||
self.file_users = [line.strip().lower() for line in f if line.strip()]
|
||||
logger.info(
|
||||
f'Successfully loaded {len(self.file_users)} users from {waitlist}'
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f'Error reading user list file {waitlist}')
|
||||
|
||||
def is_active(self) -> bool:
|
||||
if os.getenv('DISABLE_WAITLIST', '').lower() == 'true':
|
||||
logger.info('Waitlist disabled via DISABLE_WAITLIST env var')
|
||||
return False
|
||||
return bool(self.file_users)
|
||||
|
||||
def is_user_allowed(self, username: str) -> bool:
|
||||
"""Check if user is allowed based on file and/or sheet configuration."""
|
||||
logger.debug(f'Checking if GitHub user {username} is allowed')
|
||||
if self.file_users:
|
||||
if username.lower() in self.file_users:
|
||||
logger.debug(f'User {username} found in text file allowlist')
|
||||
return True
|
||||
logger.debug(f'User {username} not found in text file allowlist')
|
||||
|
||||
logger.debug(f'User {username} not found in any allowlist')
|
||||
return False
|
||||
|
||||
|
||||
user_verifier = UserVerifier()
|
||||
@@ -157,9 +157,9 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
|
||||
}
|
||||
|
||||
|
||||
async def get_user_org_role(user_id: str, org_id: UUID | None) -> Role | None:
|
||||
def get_user_org_role(user_id: str, org_id: UUID | None) -> Role | None:
|
||||
"""
|
||||
Get the user's role in an organization.
|
||||
Get the user's role in an organization (synchronous version).
|
||||
|
||||
Args:
|
||||
user_id: User ID (string that will be converted to UUID)
|
||||
@@ -171,15 +171,40 @@ async def get_user_org_role(user_id: str, org_id: UUID | None) -> Role | None:
|
||||
from uuid import UUID as parse_uuid
|
||||
|
||||
if org_id is None:
|
||||
org_member = await OrgMemberStore.get_org_member_for_current_org(
|
||||
parse_uuid(user_id)
|
||||
)
|
||||
org_member = OrgMemberStore.get_org_member_for_current_org(parse_uuid(user_id))
|
||||
else:
|
||||
org_member = await OrgMemberStore.get_org_member(org_id, parse_uuid(user_id))
|
||||
org_member = OrgMemberStore.get_org_member(org_id, parse_uuid(user_id))
|
||||
if not org_member:
|
||||
return None
|
||||
|
||||
return await RoleStore.get_role_by_id(org_member.role_id)
|
||||
return RoleStore.get_role_by_id(org_member.role_id)
|
||||
|
||||
|
||||
async def get_user_org_role_async(user_id: str, org_id: UUID | None) -> Role | None:
|
||||
"""
|
||||
Get the user's role in an organization (async version).
|
||||
|
||||
Args:
|
||||
user_id: User ID (string that will be converted to UUID)
|
||||
org_id: Organization ID, or None to use the user's current organization
|
||||
|
||||
Returns:
|
||||
Role object if user is a member, None otherwise
|
||||
"""
|
||||
from uuid import UUID as parse_uuid
|
||||
|
||||
if org_id is None:
|
||||
org_member = await OrgMemberStore.get_org_member_for_current_org_async(
|
||||
parse_uuid(user_id)
|
||||
)
|
||||
else:
|
||||
org_member = await OrgMemberStore.get_org_member_async(
|
||||
org_id, parse_uuid(user_id)
|
||||
)
|
||||
if not org_member:
|
||||
return None
|
||||
|
||||
return await RoleStore.get_role_by_id_async(org_member.role_id)
|
||||
|
||||
|
||||
def get_role_permissions(role_name: str) -> frozenset[Permission]:
|
||||
@@ -249,7 +274,7 @@ def require_permission(permission: Permission):
|
||||
detail='User not authenticated',
|
||||
)
|
||||
|
||||
user_role = await get_user_org_role(user_id, org_id)
|
||||
user_role = await get_user_org_role_async(user_id, org_id)
|
||||
|
||||
if not user_role:
|
||||
logger.warning(
|
||||
|
||||
@@ -40,16 +40,6 @@ ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in (
|
||||
)
|
||||
|
||||
DUPLICATE_EMAIL_CHECK = os.getenv('DUPLICATE_EMAIL_CHECK', 'true') in ('1', 'true')
|
||||
BITBUCKET_DATA_CENTER_CLIENT_ID = os.getenv(
|
||||
'BITBUCKET_DATA_CENTER_CLIENT_ID', ''
|
||||
).strip()
|
||||
BITBUCKET_DATA_CENTER_CLIENT_SECRET = os.getenv(
|
||||
'BITBUCKET_DATA_CENTER_CLIENT_SECRET', ''
|
||||
).strip()
|
||||
BITBUCKET_DATA_CENTER_HOST = os.getenv('BITBUCKET_DATA_CENTER_HOST', '').strip()
|
||||
BITBUCKET_DATA_CENTER_TOKEN_URL = (
|
||||
f'https://{BITBUCKET_DATA_CENTER_HOST}/rest/oauth2/latest/token'
|
||||
)
|
||||
|
||||
# reCAPTCHA Enterprise
|
||||
RECAPTCHA_PROJECT_ID = os.getenv('RECAPTCHA_PROJECT_ID', '').strip()
|
||||
|
||||
67
enterprise/server/auth/domain_blocker.py
Normal file
67
enterprise/server/auth/domain_blocker.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from storage.blocked_email_domain_store import BlockedEmailDomainStore
|
||||
from storage.database import session_maker
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class DomainBlocker:
|
||||
def __init__(self, store: BlockedEmailDomainStore) -> None:
|
||||
logger.debug('Initializing DomainBlocker')
|
||||
self.store = store
|
||||
|
||||
def _extract_domain(self, email: str) -> str | None:
|
||||
"""Extract and normalize email domain from email address"""
|
||||
if not email:
|
||||
return None
|
||||
try:
|
||||
# Extract domain part after @
|
||||
if '@' not in email:
|
||||
return None
|
||||
domain = email.split('@')[1].strip().lower()
|
||||
return domain if domain else None
|
||||
except Exception:
|
||||
logger.debug(f'Error extracting domain from email: {email}', exc_info=True)
|
||||
return None
|
||||
|
||||
def is_domain_blocked(self, email: str) -> bool:
|
||||
"""Check if email domain is blocked by querying the database directly via SQL.
|
||||
|
||||
Supports blocking:
|
||||
- Exact domains: 'example.com' blocks 'user@example.com'
|
||||
- Subdomains: 'example.com' blocks 'user@subdomain.example.com'
|
||||
- TLDs: '.us' blocks 'user@company.us' and 'user@subdomain.company.us'
|
||||
|
||||
The blocking logic is handled efficiently in SQL, avoiding the need to load
|
||||
all blocked domains into memory.
|
||||
"""
|
||||
if not email:
|
||||
logger.debug('No email provided for domain check')
|
||||
return False
|
||||
|
||||
domain = self._extract_domain(email)
|
||||
if not domain:
|
||||
logger.debug(f'Could not extract domain from email: {email}')
|
||||
return False
|
||||
|
||||
try:
|
||||
# Query database directly via SQL to check if domain is blocked
|
||||
is_blocked = self.store.is_domain_blocked(domain)
|
||||
|
||||
if is_blocked:
|
||||
logger.warning(f'Email domain {domain} is blocked for email: {email}')
|
||||
else:
|
||||
logger.debug(f'Email domain {domain} is not blocked')
|
||||
|
||||
return is_blocked
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error checking if domain is blocked for email {email}: {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
# Fail-safe: if database query fails, don't block (allow auth to proceed)
|
||||
return False
|
||||
|
||||
|
||||
# Initialize store and domain blocker
|
||||
_store = BlockedEmailDomainStore(session_maker=session_maker)
|
||||
domain_blocker = DomainBlocker(store=_store)
|
||||
@@ -1,7 +1,7 @@
|
||||
from integrations.github.github_service import SaaSGitHubService
|
||||
from pydantic import SecretStr
|
||||
from server.auth.auth_utils import user_verifier
|
||||
|
||||
from enterprise.server.auth.auth_utils import user_verifier
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.github_types import GitHubUser
|
||||
|
||||
|
||||
@@ -13,19 +13,16 @@ from server.auth.auth_error import (
|
||||
ExpiredError,
|
||||
NoCredentialsError,
|
||||
)
|
||||
from server.auth.constants import BITBUCKET_DATA_CENTER_HOST
|
||||
from server.auth.domain_blocker import domain_blocker
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from server.logger import logger
|
||||
from server.rate_limit import RateLimiter, create_redis_rate_limiter
|
||||
from sqlalchemy import delete, select
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.auth_tokens import AuthTokens
|
||||
from storage.database import a_session_maker
|
||||
from storage.database import session_maker
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
from storage.user_authorization import UserAuthorizationType
|
||||
from storage.user_authorization_store import UserAuthorizationStore
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
|
||||
|
||||
from openhands.integrations.provider import (
|
||||
@@ -121,12 +118,13 @@ class SaasUserAuth(UserAuth):
|
||||
self._settings = settings
|
||||
return settings
|
||||
|
||||
async def get_secrets_store(self) -> SaasSecretsStore:
|
||||
async def get_secrets_store(self):
|
||||
logger.debug('saas_user_auth_get_secrets_store')
|
||||
secrets_store = self.secrets_store
|
||||
if secrets_store:
|
||||
return secrets_store
|
||||
secrets_store = SaasSecretsStore(self.user_id, get_config())
|
||||
user_id = await self.get_user_id()
|
||||
secrets_store = SaasSecretsStore(user_id, session_maker, get_config())
|
||||
self.secrets_store = secrets_store
|
||||
return secrets_store
|
||||
|
||||
@@ -163,13 +161,12 @@ class SaasUserAuth(UserAuth):
|
||||
|
||||
try:
|
||||
# TODO: I think we can do this in a single request if we refactor
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(AuthTokens).where(
|
||||
AuthTokens.keycloak_user_id == self.user_id
|
||||
)
|
||||
with session_maker() as session:
|
||||
tokens = (
|
||||
session.query(AuthTokens)
|
||||
.where(AuthTokens.keycloak_user_id == self.user_id)
|
||||
.all()
|
||||
)
|
||||
tokens = result.scalars().all()
|
||||
|
||||
for token in tokens:
|
||||
idp_type = ProviderType(token.identity_provider)
|
||||
@@ -178,9 +175,6 @@ class SaasUserAuth(UserAuth):
|
||||
if user_secrets and idp_type in user_secrets.provider_tokens:
|
||||
host = user_secrets.provider_tokens[idp_type].host
|
||||
|
||||
if idp_type == ProviderType.BITBUCKET_DATA_CENTER and not host:
|
||||
host = BITBUCKET_DATA_CENTER_HOST or None
|
||||
|
||||
provider_token = await token_manager.get_idp_token(
|
||||
access_token.get_secret_value(),
|
||||
idp=idp_type,
|
||||
@@ -198,11 +192,11 @@ class SaasUserAuth(UserAuth):
|
||||
'idp_type': token.identity_provider,
|
||||
},
|
||||
)
|
||||
async with a_session_maker() as session:
|
||||
await session.execute(
|
||||
delete(AuthTokens).where(AuthTokens.id == token.id)
|
||||
)
|
||||
await session.commit()
|
||||
with session_maker() as session:
|
||||
session.query(AuthTokens).filter(
|
||||
AuthTokens.id == token.id
|
||||
).delete()
|
||||
session.commit()
|
||||
raise
|
||||
|
||||
self.provider_tokens = MappingProxyType(provider_tokens)
|
||||
@@ -215,7 +209,8 @@ class SaasUserAuth(UserAuth):
|
||||
settings_store = self.settings_store
|
||||
if settings_store:
|
||||
return settings_store
|
||||
settings_store = SaasSettingsStore(self.user_id, get_config())
|
||||
user_id = await self.get_user_id()
|
||||
settings_store = SaasSettingsStore(user_id, session_maker, get_config())
|
||||
self.settings_store = settings_store
|
||||
return settings_store
|
||||
|
||||
@@ -283,7 +278,7 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
|
||||
return None
|
||||
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
user_id = await api_key_store.validate_api_key(api_key)
|
||||
user_id = api_key_store.validate_api_key(api_key)
|
||||
if not user_id:
|
||||
return None
|
||||
offline_token = await token_manager.load_offline_token(user_id)
|
||||
@@ -331,16 +326,14 @@ async def saas_user_auth_from_signed_token(signed_token: str) -> SaasUserAuth:
|
||||
email = access_token_payload['email']
|
||||
email_verified = access_token_payload['email_verified']
|
||||
|
||||
# Check if email is blacklisted (whitelist takes precedence)
|
||||
if email:
|
||||
auth_type = await UserAuthorizationStore.get_authorization_type(email, None)
|
||||
if auth_type == UserAuthorizationType.BLACKLIST:
|
||||
logger.warning(
|
||||
f'Blocked authentication attempt for existing user with email: {email}'
|
||||
)
|
||||
raise AuthError(
|
||||
'Access denied: Your email domain is not allowed to access this service'
|
||||
)
|
||||
# Check if email domain is blocked
|
||||
if email and domain_blocker.is_domain_blocked(email):
|
||||
logger.warning(
|
||||
f'Blocked authentication attempt for existing user with email: {email}'
|
||||
)
|
||||
raise AuthError(
|
||||
'Access denied: Your email domain is not allowed to access this service'
|
||||
)
|
||||
|
||||
logger.debug('saas_user_auth_from_signed_token:return')
|
||||
|
||||
|
||||
@@ -16,15 +16,10 @@ from keycloak.exceptions import (
|
||||
KeycloakError,
|
||||
KeycloakPostError,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from server.auth.auth_error import ExpiredError
|
||||
from server.auth.constants import (
|
||||
BITBUCKET_APP_CLIENT_ID,
|
||||
BITBUCKET_APP_CLIENT_SECRET,
|
||||
BITBUCKET_DATA_CENTER_CLIENT_ID,
|
||||
BITBUCKET_DATA_CENTER_CLIENT_SECRET,
|
||||
BITBUCKET_DATA_CENTER_HOST,
|
||||
BITBUCKET_DATA_CENTER_TOKEN_URL,
|
||||
DUPLICATE_EMAIL_CHECK,
|
||||
GITHUB_APP_CLIENT_ID,
|
||||
GITHUB_APP_CLIENT_SECRET,
|
||||
@@ -43,9 +38,9 @@ from server.auth.keycloak_manager import get_keycloak_admin, get_keycloak_openid
|
||||
from server.config import get_config
|
||||
from server.logger import logger
|
||||
from sqlalchemy import String as SQLString
|
||||
from sqlalchemy import select, type_coerce
|
||||
from sqlalchemy import type_coerce
|
||||
from storage.auth_token_store import AuthTokenStore
|
||||
from storage.database import a_session_maker
|
||||
from storage.database import session_maker
|
||||
from storage.github_app_installation import GithubAppInstallation
|
||||
from storage.offline_token_store import OfflineTokenStore
|
||||
from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt
|
||||
@@ -54,30 +49,6 @@ from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.types import SessionExpiredError
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
|
||||
class KeycloakUserInfo(BaseModel):
|
||||
"""Pydantic model for Keycloak UserInfo endpoint response.
|
||||
|
||||
Based on OIDC standard claims. 'sub' is always required per OIDC spec.
|
||||
Additional fields from Keycloak are captured via model_config extra='allow'.
|
||||
"""
|
||||
|
||||
model_config = {'extra': 'allow'}
|
||||
|
||||
sub: str
|
||||
name: str | None = None
|
||||
given_name: str | None = None
|
||||
family_name: str | None = None
|
||||
preferred_username: str | None = None
|
||||
email: str | None = None
|
||||
email_verified: bool | None = None
|
||||
picture: str | None = None
|
||||
attributes: dict[str, list[str]] | None = None
|
||||
identity_provider: str | None = None
|
||||
company: str | None = None
|
||||
roles: list[str] | None = None
|
||||
|
||||
|
||||
# HTTP timeout for external IDP calls (in seconds)
|
||||
# This prevents indefinite blocking if an IDP is slow or unresponsive
|
||||
IDP_HTTP_TIMEOUT = 15.0
|
||||
@@ -170,22 +141,22 @@ class TokenManager:
|
||||
new_keycloak_tokens['refresh_token'],
|
||||
)
|
||||
|
||||
async def get_user_info(self, access_token: str) -> KeycloakUserInfo:
|
||||
"""Get user info from Keycloak userinfo endpoint.
|
||||
|
||||
Args:
|
||||
access_token: A valid Keycloak access token
|
||||
|
||||
Returns:
|
||||
KeycloakUserInfo with user claims. 'sub' is always present per OIDC spec.
|
||||
|
||||
Raises:
|
||||
KeycloakAuthenticationError: If the token is invalid
|
||||
ValidationError: If the response is missing the required 'sub' field
|
||||
"""
|
||||
# UserInfo from Keycloak return a dictionary with the following format:
|
||||
# {
|
||||
# 'sub': '248289761001',
|
||||
# 'name': 'Jane Doe',
|
||||
# 'given_name': 'Jane',
|
||||
# 'family_name': 'Doe',
|
||||
# 'preferred_username': 'j.doe',
|
||||
# 'email': 'janedoe@example.com',
|
||||
# 'picture': 'http://example.com/janedoe/me.jpg'
|
||||
# 'github_id': '354322532'
|
||||
# }
|
||||
async def get_user_info(self, access_token: str) -> dict:
|
||||
if not access_token:
|
||||
return {}
|
||||
user_info = await get_keycloak_openid(self.external).a_userinfo(access_token)
|
||||
# Pydantic validation will raise ValidationError if 'sub' is missing
|
||||
return KeycloakUserInfo.model_validate(user_info)
|
||||
return user_info
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(2),
|
||||
@@ -299,8 +270,8 @@ class TokenManager:
|
||||
) -> str:
|
||||
# Get user info to determine user_id and idp
|
||||
user_info = await self.get_user_info(access_token=access_token)
|
||||
user_id = user_info.sub
|
||||
username = user_info.preferred_username
|
||||
user_id = user_info.get('sub')
|
||||
username = user_info.get('preferred_username')
|
||||
logger.info(f'Getting token for user {username} and IDP {idp}')
|
||||
token_store = await AuthTokenStore.get_instance(
|
||||
keycloak_user_id=user_id, idp=idp
|
||||
@@ -383,8 +354,6 @@ class TokenManager:
|
||||
return await self._refresh_gitlab_token(refresh_token)
|
||||
elif idp == ProviderType.BITBUCKET:
|
||||
return await self._refresh_bitbucket_token(refresh_token)
|
||||
elif idp == ProviderType.BITBUCKET_DATA_CENTER:
|
||||
return await self._refresh_bitbucket_data_center_token(refresh_token)
|
||||
else:
|
||||
raise ValueError(f'Unsupported IDP: {idp}')
|
||||
|
||||
@@ -466,33 +435,6 @@ class TokenManager:
|
||||
data = response.json()
|
||||
return await self._parse_refresh_response(data)
|
||||
|
||||
async def _refresh_bitbucket_data_center_token(
|
||||
self, refresh_token: str
|
||||
) -> dict[str, str | int]:
|
||||
if not BITBUCKET_DATA_CENTER_HOST:
|
||||
raise ValueError(
|
||||
'BITBUCKET_DATA_CENTER_HOST is not configured. '
|
||||
'Set the BITBUCKET_DATA_CENTER_HOST environment variable.'
|
||||
)
|
||||
url = BITBUCKET_DATA_CENTER_TOKEN_URL
|
||||
logger.info(f'Refreshing Bitbucket Data Center token with URL: {url}')
|
||||
|
||||
payload = {
|
||||
'client_id': BITBUCKET_DATA_CENTER_CLIENT_ID,
|
||||
'client_secret': BITBUCKET_DATA_CENTER_CLIENT_SECRET,
|
||||
'refresh_token': refresh_token,
|
||||
'grant_type': 'refresh_token',
|
||||
}
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
|
||||
) as client:
|
||||
response = await client.post(url, data=payload)
|
||||
response.raise_for_status()
|
||||
logger.info('Successfully refreshed Bitbucket Data Center token')
|
||||
|
||||
data = response.json()
|
||||
return await self._parse_refresh_response(data)
|
||||
|
||||
async def _parse_refresh_response(self, data: dict) -> dict[str, str | int]:
|
||||
access_token = data.get('access_token')
|
||||
refresh_token = data.get('refresh_token')
|
||||
@@ -841,24 +783,25 @@ class TokenManager:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def store_org_token(self, installation_id: int, installation_token: str):
|
||||
def store_org_token(self, installation_id: int, installation_token: str):
|
||||
"""Store a GitHub App installation token.
|
||||
|
||||
Args:
|
||||
installation_id: GitHub installation ID (integer or string)
|
||||
installation_token: The token to store
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
with session_maker() as session:
|
||||
# Ensure installation_id is a string
|
||||
str_installation_id = str(installation_id)
|
||||
# Use type_coerce to ensure SQLAlchemy treats the parameter as a string
|
||||
result = await session.execute(
|
||||
select(GithubAppInstallation).filter(
|
||||
installation = (
|
||||
session.query(GithubAppInstallation)
|
||||
.filter(
|
||||
GithubAppInstallation.installation_id
|
||||
== type_coerce(str_installation_id, SQLString)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
installation = result.scalars().first()
|
||||
if installation:
|
||||
installation.encrypted_token = self.encrypt_text(installation_token)
|
||||
else:
|
||||
@@ -868,9 +811,9 @@ class TokenManager:
|
||||
encrypted_token=self.encrypt_text(installation_token),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
session.commit()
|
||||
|
||||
async def load_org_token(self, installation_id: int) -> str | None:
|
||||
def load_org_token(self, installation_id: int) -> str | None:
|
||||
"""Load a GitHub App installation token.
|
||||
|
||||
Args:
|
||||
@@ -879,16 +822,17 @@ class TokenManager:
|
||||
Returns:
|
||||
The decrypted token if found, None otherwise
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
with session_maker() as session:
|
||||
# Ensure installation_id is a string and use type_coerce
|
||||
str_installation_id = str(installation_id)
|
||||
result = await session.execute(
|
||||
select(GithubAppInstallation).filter(
|
||||
installation = (
|
||||
session.query(GithubAppInstallation)
|
||||
.filter(
|
||||
GithubAppInstallation.installation_id
|
||||
== type_coerce(str_installation_id, SQLString)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
installation = result.scalars().first()
|
||||
if not installation:
|
||||
return None
|
||||
token = self.decrypt_text(installation.encrypted_token)
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import Field
|
||||
from server.auth.email_validation import extract_base_email
|
||||
from server.auth.token_manager import KeycloakUserInfo, TokenManager
|
||||
from server.auth.user.user_authorizer import (
|
||||
UserAuthorizationResponse,
|
||||
UserAuthorizer,
|
||||
UserAuthorizerInjector,
|
||||
)
|
||||
from storage.user_authorization import UserAuthorizationType
|
||||
from storage.user_authorization_store import UserAuthorizationStore
|
||||
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
token_manager = TokenManager()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DefaultUserAuthorizer(UserAuthorizer):
|
||||
"""Class determining whether a user may be authorized.
|
||||
|
||||
Uses the user_authorizations database table to check whitelist/blacklist rules.
|
||||
"""
|
||||
|
||||
prevent_duplicates: bool
|
||||
|
||||
async def authorize_user(
|
||||
self, user_info: KeycloakUserInfo
|
||||
) -> UserAuthorizationResponse:
|
||||
user_id = user_info.sub
|
||||
email = user_info.email
|
||||
provider_type = user_info.identity_provider
|
||||
try:
|
||||
if not email:
|
||||
logger.warning(f'No email provided for user_id: {user_id}')
|
||||
return UserAuthorizationResponse(
|
||||
success=False, error_detail='missing_email'
|
||||
)
|
||||
|
||||
if self.prevent_duplicates:
|
||||
has_duplicate = await token_manager.check_duplicate_base_email(
|
||||
email, user_id
|
||||
)
|
||||
if has_duplicate:
|
||||
logger.warning(
|
||||
f'Blocked signup attempt for email {email} - duplicate base email found',
|
||||
extra={'user_id': user_id, 'email': email},
|
||||
)
|
||||
return UserAuthorizationResponse(
|
||||
success=False, error_detail='duplicate_email'
|
||||
)
|
||||
|
||||
# Check authorization rules (whitelist takes precedence over blacklist)
|
||||
base_email = extract_base_email(email)
|
||||
if base_email is None:
|
||||
return UserAuthorizationResponse(
|
||||
success=False, error_detail='invalid_email'
|
||||
)
|
||||
auth_type = await UserAuthorizationStore.get_authorization_type(
|
||||
base_email, provider_type
|
||||
)
|
||||
|
||||
if auth_type == UserAuthorizationType.WHITELIST:
|
||||
logger.debug(
|
||||
f'User {email} matched whitelist rule',
|
||||
extra={'user_id': user_id, 'email': email},
|
||||
)
|
||||
return UserAuthorizationResponse(success=True)
|
||||
|
||||
if auth_type == UserAuthorizationType.BLACKLIST:
|
||||
logger.warning(
|
||||
f'Blocked authentication attempt for email: {email}, user_id: {user_id}'
|
||||
)
|
||||
return UserAuthorizationResponse(success=False, error_detail='blocked')
|
||||
|
||||
return UserAuthorizationResponse(success=True)
|
||||
except Exception:
|
||||
logger.exception('error authorizing user', extra={'user_id': user_id})
|
||||
return UserAuthorizationResponse(success=False)
|
||||
|
||||
|
||||
class DefaultUserAuthorizerInjector(UserAuthorizerInjector):
|
||||
prevent_duplicates: bool = Field(
|
||||
default=True,
|
||||
description='Whether duplicate emails (containing +) are filtered',
|
||||
)
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[UserAuthorizer, None]:
|
||||
yield DefaultUserAuthorizer(
|
||||
prevent_duplicates=self.prevent_duplicates,
|
||||
)
|
||||
@@ -1,48 +0,0 @@
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from fastapi import Depends
|
||||
from pydantic import BaseModel
|
||||
from server.auth.token_manager import KeycloakUserInfo
|
||||
|
||||
from openhands.agent_server.env_parser import from_env
|
||||
from openhands.app_server.services.injector import Injector
|
||||
from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserAuthorizationResponse(BaseModel):
|
||||
success: bool
|
||||
error_detail: str | None = None
|
||||
|
||||
|
||||
class UserAuthorizer(ABC):
|
||||
"""Class determining whether a user may be authorized."""
|
||||
|
||||
@abstractmethod
|
||||
async def authorize_user(
|
||||
self, user_info: KeycloakUserInfo
|
||||
) -> UserAuthorizationResponse:
|
||||
"""Determine whether the info given is permitted."""
|
||||
|
||||
|
||||
class UserAuthorizerInjector(DiscriminatedUnionMixin, Injector[UserAuthorizer], ABC):
|
||||
pass
|
||||
|
||||
|
||||
def depends_user_authorizer():
|
||||
from server.auth.user.default_user_authorizer import (
|
||||
DefaultUserAuthorizerInjector,
|
||||
)
|
||||
|
||||
try:
|
||||
injector: UserAuthorizerInjector = from_env(
|
||||
UserAuthorizerInjector, 'OH_USER_AUTHORIZER'
|
||||
)
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
logger.info('Using default UserAuthorizer')
|
||||
injector = DefaultUserAuthorizerInjector()
|
||||
|
||||
return Depends(injector.depends)
|
||||
@@ -7,8 +7,7 @@ from uuid import uuid4
|
||||
import socketio
|
||||
from server.logger import logger
|
||||
from server.utils.conversation_callback_utils import invoke_conversation_callbacks
|
||||
from sqlalchemy import select
|
||||
from storage.database import a_session_maker
|
||||
from storage.database import session_maker
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
@@ -524,14 +523,15 @@ class ClusteredConversationManager(StandaloneConversationManager):
|
||||
f'local_connection_to_stopped_conversation:{connection_id}:{conversation_id}'
|
||||
)
|
||||
# Look up the user_id from the database
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(StoredConversationMetadataSaas).where(
|
||||
with session_maker() as session:
|
||||
conversation_metadata_saas = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(
|
||||
StoredConversationMetadataSaas.conversation_id
|
||||
== conversation_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
conversation_metadata_saas = result.scalars().first()
|
||||
user_id = (
|
||||
str(conversation_metadata_saas.user_id)
|
||||
if conversation_metadata_saas
|
||||
@@ -749,9 +749,6 @@ class ClusteredConversationManager(StandaloneConversationManager):
|
||||
config = load_openhands_config()
|
||||
settings_store = await SaasSettingsStore.get_instance(config, user_id)
|
||||
settings = await settings_store.load()
|
||||
if not settings:
|
||||
logger.error(f'Failed to load settings for user {user_id}')
|
||||
return
|
||||
await self.maybe_start_agent_loop(conversation_id, settings, user_id)
|
||||
|
||||
async def _start_agent_loop(
|
||||
|
||||
@@ -9,7 +9,6 @@ import requests # type: ignore
|
||||
from fastapi import HTTPException
|
||||
from server.auth.constants import (
|
||||
BITBUCKET_APP_CLIENT_ID,
|
||||
BITBUCKET_DATA_CENTER_CLIENT_ID,
|
||||
ENABLE_ENTERPRISE_SSO,
|
||||
ENABLE_JIRA,
|
||||
ENABLE_JIRA_DC,
|
||||
@@ -165,9 +164,6 @@ class SaaSServerConfig(ServerConfig):
|
||||
if ENABLE_ENTERPRISE_SSO:
|
||||
providers_configured.append(ProviderType.ENTERPRISE_SSO)
|
||||
|
||||
if BITBUCKET_DATA_CENTER_CLIENT_ID:
|
||||
providers_configured.append(ProviderType.BITBUCKET_DATA_CENTER)
|
||||
|
||||
config: dict[str, typing.Any] = {
|
||||
'APP_MODE': self.app_mode,
|
||||
'APP_SLUG': self.app_slug,
|
||||
|
||||
@@ -77,9 +77,6 @@ PERMITTED_CORS_ORIGINS = [
|
||||
)
|
||||
]
|
||||
|
||||
# Controls whether new orgs/users default to V1 API (env: DEFAULT_V1_ENABLED)
|
||||
DEFAULT_V1_ENABLED = os.getenv('DEFAULT_V1_ENABLED', '1').lower() in ('1', 'true')
|
||||
|
||||
|
||||
def build_litellm_proxy_model_path(model_name: str) -> str:
|
||||
"""Build the LiteLLM proxy model path based on model name.
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
||||
|
||||
from integrations.github.github_manager import GithubManager
|
||||
from integrations.github.github_view import GithubViewType
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.utils import (
|
||||
extract_summary_from_conversation_manager,
|
||||
get_summary_instruction,
|
||||
@@ -34,12 +35,16 @@ class GithubCallbackProcessor(ConversationCallbackProcessor):
|
||||
send_summary_instruction: bool = True
|
||||
|
||||
async def _send_message_to_github(self, message: str) -> None:
|
||||
"""Send a message to GitHub.
|
||||
"""
|
||||
Send a message to GitHub.
|
||||
|
||||
Args:
|
||||
message: The message content to send to GitHub
|
||||
"""
|
||||
try:
|
||||
# Create a message object for GitHub
|
||||
message_obj = Message(source=SourceType.OPENHANDS, message=message)
|
||||
|
||||
# Get the token manager
|
||||
token_manager = TokenManager()
|
||||
|
||||
@@ -48,8 +53,8 @@ class GithubCallbackProcessor(ConversationCallbackProcessor):
|
||||
|
||||
github_manager = GithubManager(token_manager, GitHubDataCollector())
|
||||
|
||||
# Send the message directly as a string
|
||||
await github_manager.send_message(message, self.github_view)
|
||||
# Send the message
|
||||
await github_manager.send_message(message_obj, self.github_view)
|
||||
|
||||
logger.info(
|
||||
f'[GitHub] Sent summary message to {self.github_view.full_repo_name}#{self.github_view.issue_number}'
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
||||
|
||||
from integrations.gitlab.gitlab_manager import GitlabManager
|
||||
from integrations.gitlab.gitlab_view import GitlabViewType
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.utils import (
|
||||
extract_summary_from_conversation_manager,
|
||||
get_summary_instruction,
|
||||
@@ -13,7 +14,7 @@ from storage.conversation_callback import (
|
||||
ConversationCallback,
|
||||
ConversationCallbackProcessor,
|
||||
)
|
||||
from storage.database import a_session_maker
|
||||
from storage.database import session_maker
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
@@ -27,7 +28,8 @@ gitlab_manager = GitlabManager(token_manager)
|
||||
|
||||
|
||||
class GitlabCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""Processor for sending conversation summaries to GitLab.
|
||||
"""
|
||||
Processor for sending conversation summaries to GitLab.
|
||||
|
||||
This processor is used to send summaries of conversations to GitLab
|
||||
when agent state changes occur.
|
||||
@@ -37,18 +39,22 @@ class GitlabCallbackProcessor(ConversationCallbackProcessor):
|
||||
send_summary_instruction: bool = True
|
||||
|
||||
async def _send_message_to_gitlab(self, message: str) -> None:
|
||||
"""Send a message to GitLab.
|
||||
"""
|
||||
Send a message to GitLab.
|
||||
|
||||
Args:
|
||||
message: The message content to send to GitLab
|
||||
"""
|
||||
try:
|
||||
# Create a message object for GitHub
|
||||
message_obj = Message(source=SourceType.OPENHANDS, message=message)
|
||||
|
||||
# Get the token manager
|
||||
token_manager = TokenManager()
|
||||
gitlab_manager = GitlabManager(token_manager)
|
||||
|
||||
# Send the message directly as a string
|
||||
await gitlab_manager.send_message(message, self.gitlab_view)
|
||||
# Send the message
|
||||
await gitlab_manager.send_message(message_obj, self.gitlab_view)
|
||||
|
||||
logger.info(
|
||||
f'[GitLab] Sent summary message to {self.gitlab_view.full_repo_name}#{self.gitlab_view.issue_number}'
|
||||
@@ -105,9 +111,9 @@ class GitlabCallbackProcessor(ConversationCallbackProcessor):
|
||||
self.send_summary_instruction = False
|
||||
callback.set_processor(self)
|
||||
callback.updated_at = datetime.now()
|
||||
async with a_session_maker() as session:
|
||||
with session_maker() as session:
|
||||
session.merge(callback)
|
||||
await session.commit()
|
||||
session.commit()
|
||||
return
|
||||
|
||||
# Extract the summary from the event store
|
||||
@@ -126,9 +132,9 @@ class GitlabCallbackProcessor(ConversationCallbackProcessor):
|
||||
# Mark callback as completed status
|
||||
callback.status = CallbackStatus.COMPLETED
|
||||
callback.updated_at = datetime.now()
|
||||
async with a_session_maker() as session:
|
||||
with session_maker() as session:
|
||||
session.merge(callback)
|
||||
await session.commit()
|
||||
session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
|
||||
@@ -37,7 +37,8 @@ class JiraCallbackProcessor(ConversationCallbackProcessor):
|
||||
workspace_name: str
|
||||
|
||||
async def _send_comment_to_jira(self, message: str) -> None:
|
||||
"""Send a comment to Jira issue.
|
||||
"""
|
||||
Send a comment to Jira issue.
|
||||
|
||||
Args:
|
||||
message: The message content to send to Jira
|
||||
@@ -58,9 +59,8 @@ class JiraCallbackProcessor(ConversationCallbackProcessor):
|
||||
# Decrypt API key
|
||||
api_key = jira_manager.token_manager.decrypt_text(workspace.svc_acc_api_key)
|
||||
|
||||
# Send comment directly as a string
|
||||
await jira_manager.send_message(
|
||||
message,
|
||||
jira_manager.create_outgoing_message(msg=message),
|
||||
issue_key=self.issue_key,
|
||||
jira_cloud_id=workspace.jira_cloud_id,
|
||||
svc_acc_email=workspace.svc_acc_email,
|
||||
|
||||
@@ -37,7 +37,8 @@ class JiraDcCallbackProcessor(ConversationCallbackProcessor):
|
||||
base_api_url: str
|
||||
|
||||
async def _send_comment_to_jira_dc(self, message: str) -> None:
|
||||
"""Send a comment to Jira DC issue.
|
||||
"""
|
||||
Send a comment to Jira DC issue.
|
||||
|
||||
Args:
|
||||
message: The message content to send to Jira DC
|
||||
@@ -60,9 +61,8 @@ class JiraDcCallbackProcessor(ConversationCallbackProcessor):
|
||||
workspace.svc_acc_api_key
|
||||
)
|
||||
|
||||
# Send comment directly as a string
|
||||
await jira_dc_manager.send_message(
|
||||
message,
|
||||
jira_dc_manager.create_outgoing_message(msg=message),
|
||||
issue_key=self.issue_key,
|
||||
base_api_url=self.base_api_url,
|
||||
svc_acc_api_key=api_key,
|
||||
|
||||
@@ -36,7 +36,8 @@ class LinearCallbackProcessor(ConversationCallbackProcessor):
|
||||
workspace_name: str
|
||||
|
||||
async def _send_comment_to_linear(self, message: str) -> None:
|
||||
"""Send a comment to Linear issue.
|
||||
"""
|
||||
Send a comment to Linear issue.
|
||||
|
||||
Args:
|
||||
message: The message content to send to Linear
|
||||
@@ -59,9 +60,9 @@ class LinearCallbackProcessor(ConversationCallbackProcessor):
|
||||
workspace.svc_acc_api_key
|
||||
)
|
||||
|
||||
# Send comment directly as a string
|
||||
# Send comment
|
||||
await linear_manager.send_message(
|
||||
message,
|
||||
linear_manager.create_outgoing_message(msg=message),
|
||||
self.issue_id,
|
||||
api_key,
|
||||
)
|
||||
|
||||
@@ -26,7 +26,8 @@ slack_manager = SlackManager(token_manager)
|
||||
|
||||
|
||||
class SlackCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""Processor for sending conversation summaries to Slack.
|
||||
"""
|
||||
Processor for sending conversation summaries to Slack.
|
||||
|
||||
This processor is used to send summaries of conversations to Slack channels
|
||||
when agent state changes occur.
|
||||
@@ -40,13 +41,14 @@ class SlackCallbackProcessor(ConversationCallbackProcessor):
|
||||
last_user_msg_id: int | None = None
|
||||
|
||||
async def _send_message_to_slack(self, message: str) -> None:
|
||||
"""Send a message to Slack.
|
||||
"""
|
||||
Send a message to Slack using the conversation_manager's send_to_event_stream method.
|
||||
|
||||
Args:
|
||||
message: The message content to send to Slack
|
||||
"""
|
||||
try:
|
||||
# Create a message object for Slack view creation (incoming message format)
|
||||
# Create a message object for Slack
|
||||
message_obj = Message(
|
||||
source=SourceType.SLACK,
|
||||
message={
|
||||
@@ -62,11 +64,12 @@ class SlackCallbackProcessor(ConversationCallbackProcessor):
|
||||
slack_user, saas_user_auth = await slack_manager.authenticate_user(
|
||||
self.slack_user_id
|
||||
)
|
||||
slack_view = await SlackFactory.create_slack_view_from_payload(
|
||||
slack_view = SlackFactory.create_slack_view_from_payload(
|
||||
message_obj, slack_user, saas_user_auth
|
||||
)
|
||||
# Send the message directly as a string
|
||||
await slack_manager.send_message(message, slack_view)
|
||||
await slack_manager.send_message(
|
||||
slack_manager.create_outgoing_message(message), slack_view
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Slack] Sent summary message to channel {self.channel_id} '
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Callable, cast
|
||||
from typing import Callable
|
||||
|
||||
import jwt
|
||||
from fastapi import Request, Response, status
|
||||
@@ -12,11 +12,14 @@ from server.auth.auth_error import (
|
||||
)
|
||||
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
|
||||
from server.auth.saas_user_auth import SaasUserAuth, token_manager
|
||||
from server.routes.auth import set_response_cookie
|
||||
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite
|
||||
from server.routes.auth import (
|
||||
get_cookie_domain,
|
||||
get_cookie_samesite,
|
||||
set_response_cookie,
|
||||
)
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
|
||||
from openhands.server.user_auth.user_auth import AuthType, get_user_auth
|
||||
from openhands.server.utils import config
|
||||
|
||||
|
||||
@@ -40,21 +43,19 @@ class SetAuthCookieMiddleware:
|
||||
if not user_auth or user_auth.auth_type != AuthType.COOKIE:
|
||||
return response
|
||||
if user_auth.refreshed:
|
||||
if user_auth.access_token is None:
|
||||
return response
|
||||
set_response_cookie(
|
||||
request=request,
|
||||
response=response,
|
||||
keycloak_access_token=user_auth.access_token.get_secret_value(),
|
||||
keycloak_refresh_token=user_auth.refresh_token.get_secret_value(),
|
||||
secure=False if request.url.hostname == 'localhost' else True,
|
||||
accepted_tos=user_auth.accepted_tos or False,
|
||||
accepted_tos=user_auth.accepted_tos,
|
||||
)
|
||||
|
||||
# On re-authentication (token refresh), kick off background sync for GitLab repos
|
||||
user_id = await user_auth.get_user_id()
|
||||
if user_id:
|
||||
schedule_gitlab_repo_sync(user_id)
|
||||
schedule_gitlab_repo_sync(
|
||||
await user_auth.get_user_id(),
|
||||
)
|
||||
|
||||
if (
|
||||
self._should_attach(request)
|
||||
@@ -90,16 +91,13 @@ class SetAuthCookieMiddleware:
|
||||
if keycloak_auth_cookie:
|
||||
response.delete_cookie(
|
||||
key='keycloak_auth',
|
||||
domain=get_cookie_domain(),
|
||||
samesite=get_cookie_samesite(),
|
||||
domain=get_cookie_domain(request),
|
||||
samesite=get_cookie_samesite(request),
|
||||
)
|
||||
return response
|
||||
|
||||
def _get_user_auth(self, request: Request) -> SaasUserAuth | None:
|
||||
user_auth: UserAuth | None = getattr(request.state, 'user_auth', None)
|
||||
if user_auth is None:
|
||||
return None
|
||||
return cast(SaasUserAuth, user_auth)
|
||||
return getattr(request.state, 'user_auth', None)
|
||||
|
||||
def _check_tos(self, request: Request):
|
||||
keycloak_auth_cookie = request.cookies.get('keycloak_auth')
|
||||
@@ -189,7 +187,7 @@ class SetAuthCookieMiddleware:
|
||||
async def _logout(self, request: Request):
|
||||
# Log out of keycloak - this prevents issues where you did not log in with the idp you believe you used
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
if user_auth and user_auth.refresh_token:
|
||||
await token_manager.logout(user_auth.refresh_token.get_secret_value())
|
||||
except Exception:
|
||||
|
||||
@@ -17,12 +17,12 @@ from openhands.server.user_auth import get_user_id
|
||||
# Helper functions for BYOR API key management
|
||||
async def get_byor_key_from_db(user_id: str) -> str | None:
|
||||
"""Get the BYOR key from the database for a user."""
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
current_org_id = user.current_org_id
|
||||
current_org_member: OrgMember | None = None
|
||||
current_org_member: OrgMember = None
|
||||
for org_member in user.org_members:
|
||||
if org_member.org_id == current_org_id:
|
||||
current_org_member = org_member
|
||||
@@ -36,12 +36,12 @@ async def get_byor_key_from_db(user_id: str) -> str | None:
|
||||
|
||||
async def store_byor_key_in_db(user_id: str, key: str) -> None:
|
||||
"""Store the BYOR key in the database for a user."""
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
current_org_id = user.current_org_id
|
||||
current_org_member: OrgMember | None = None
|
||||
current_org_member: OrgMember = None
|
||||
for org_member in user.org_members:
|
||||
if org_member.org_id == current_org_id:
|
||||
current_org_member = org_member
|
||||
@@ -49,13 +49,13 @@ async def store_byor_key_in_db(user_id: str, key: str) -> None:
|
||||
if not current_org_member:
|
||||
return None
|
||||
current_org_member.llm_api_key_for_byor = key
|
||||
await OrgMemberStore.update_org_member(current_org_member)
|
||||
OrgMemberStore.update_org_member(current_org_member)
|
||||
|
||||
|
||||
async def generate_byor_key(user_id: str) -> str | None:
|
||||
"""Generate a new BYOR key for a user."""
|
||||
try:
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
if not user:
|
||||
return None
|
||||
current_org_id = str(user.current_org_id)
|
||||
@@ -66,15 +66,22 @@ async def generate_byor_key(user_id: str) -> str | None:
|
||||
{'type': 'byor'},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Successfully generated new BYOR key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'key_length': len(key),
|
||||
'key_prefix': key[:10] + '...' if len(key) > 10 else key,
|
||||
},
|
||||
)
|
||||
return key
|
||||
if key:
|
||||
logger.info(
|
||||
'Successfully generated new BYOR key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'key_length': len(key) if key else 0,
|
||||
'key_prefix': key[:10] + '...' if key and len(key) > 10 else key,
|
||||
},
|
||||
)
|
||||
return key
|
||||
else:
|
||||
logger.error(
|
||||
'Failed to generate BYOR LLM API key - no key in response',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error generating BYOR key',
|
||||
@@ -91,7 +98,7 @@ async def delete_byor_key_from_litellm(user_id: str, byor_key: str) -> bool:
|
||||
"""
|
||||
try:
|
||||
# Get user to construct the key alias
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
key_alias = None
|
||||
if user and user.current_org_id:
|
||||
key_alias = f'BYOR Key - user {user_id}, org {user.current_org_id}'
|
||||
@@ -244,7 +251,7 @@ async def delete_api_key(
|
||||
)
|
||||
|
||||
# Delete the key
|
||||
success = await api_key_store.delete_api_key_by_id(key_id)
|
||||
success = api_key_store.delete_api_key_by_id(key_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -3,14 +3,15 @@ import json
|
||||
import uuid
|
||||
import warnings
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Optional, cast
|
||||
from urllib.parse import quote, urlencode
|
||||
from typing import Annotated, Literal, Optional
|
||||
from urllib.parse import quote
|
||||
from uuid import UUID as parse_uuid
|
||||
|
||||
import posthog
|
||||
from fastapi import APIRouter, Header, HTTPException, Request, Response, status
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from pydantic import SecretStr
|
||||
from server.auth.auth_utils import user_verifier
|
||||
from server.auth.constants import (
|
||||
KEYCLOAK_CLIENT_ID,
|
||||
KEYCLOAK_REALM_NAME,
|
||||
@@ -18,16 +19,13 @@ from server.auth.constants import (
|
||||
RECAPTCHA_SITE_KEY,
|
||||
ROLE_CHECK_ENABLED,
|
||||
)
|
||||
from server.auth.domain_blocker import domain_blocker
|
||||
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
|
||||
from server.auth.recaptcha_service import recaptcha_service
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.auth.user.user_authorizer import (
|
||||
UserAuthorizer,
|
||||
depends_user_authorizer,
|
||||
)
|
||||
from server.config import sign_token
|
||||
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV
|
||||
from server.constants import IS_FEATURE_ENV
|
||||
from server.routes.event_webhook import _get_session_api_key, _get_user_id
|
||||
from server.services.org_invitation_service import (
|
||||
EmailMismatchError,
|
||||
@@ -36,10 +34,7 @@ from server.services.org_invitation_service import (
|
||||
OrgInvitationService,
|
||||
UserAlreadyMemberError,
|
||||
)
|
||||
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite, get_web_url
|
||||
from sqlalchemy import select
|
||||
from storage.database import a_session_maker
|
||||
from storage.database import session_maker
|
||||
from storage.user import User
|
||||
from storage.user_store import UserStore
|
||||
|
||||
@@ -77,7 +72,7 @@ def set_response_cookie(
|
||||
signed_token = sign_token(cookie_data, config.jwt_secret.get_secret_value()) # type: ignore
|
||||
|
||||
# Set secure cookie with signed token
|
||||
domain = get_cookie_domain()
|
||||
domain = get_cookie_domain(request)
|
||||
if domain:
|
||||
response.set_cookie(
|
||||
key='keycloak_auth',
|
||||
@@ -85,7 +80,7 @@ def set_response_cookie(
|
||||
domain=domain,
|
||||
httponly=True,
|
||||
secure=secure,
|
||||
samesite=get_cookie_samesite(),
|
||||
samesite=get_cookie_samesite(request),
|
||||
)
|
||||
else:
|
||||
response.set_cookie(
|
||||
@@ -93,10 +88,30 @@ def set_response_cookie(
|
||||
value=signed_token,
|
||||
httponly=True,
|
||||
secure=secure,
|
||||
samesite=get_cookie_samesite(),
|
||||
samesite=get_cookie_samesite(request),
|
||||
)
|
||||
|
||||
|
||||
def get_cookie_domain(request: Request) -> str | None:
|
||||
# for now just use the full hostname except for staging stacks.
|
||||
return (
|
||||
None
|
||||
if not request.url.hostname
|
||||
or request.url.hostname.endswith('staging.all-hands.dev')
|
||||
else request.url.hostname
|
||||
)
|
||||
|
||||
|
||||
def get_cookie_samesite(request: Request) -> Literal['lax', 'strict']:
|
||||
# for localhost and feature/staging stacks we set it to 'lax' as the cookie domain won't allow 'strict'
|
||||
return (
|
||||
'lax'
|
||||
if request.url.hostname == 'localhost'
|
||||
or (request.url.hostname or '').endswith('staging.all-hands.dev')
|
||||
else 'strict'
|
||||
)
|
||||
|
||||
|
||||
def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None]:
|
||||
"""Extract redirect URL, reCAPTCHA token, and invitation token from OAuth state.
|
||||
|
||||
@@ -120,6 +135,19 @@ def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None
|
||||
return state, None, None
|
||||
|
||||
|
||||
# Keep alias for backward compatibility
|
||||
def _extract_recaptcha_state(state: str | None) -> tuple[str, str | None]:
|
||||
"""Extract redirect URL and reCAPTCHA token from OAuth state.
|
||||
|
||||
Deprecated: Use _extract_oauth_state instead.
|
||||
|
||||
Returns:
|
||||
Tuple of (redirect_url, recaptcha_token). Token may be None.
|
||||
"""
|
||||
redirect_url, recaptcha_token, _ = _extract_oauth_state(state)
|
||||
return redirect_url, recaptcha_token
|
||||
|
||||
|
||||
@oauth_router.get('/keycloak/callback')
|
||||
async def keycloak_callback(
|
||||
request: Request,
|
||||
@@ -127,16 +155,11 @@ async def keycloak_callback(
|
||||
state: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
error_description: Optional[str] = None,
|
||||
user_authorizer: UserAuthorizer = depends_user_authorizer(),
|
||||
):
|
||||
# Extract redirect URL, reCAPTCHA token, and invitation token from state
|
||||
redirect_url, recaptcha_token, invitation_token = _extract_oauth_state(state)
|
||||
|
||||
if redirect_url is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Missing state in request params',
|
||||
)
|
||||
if not redirect_url:
|
||||
redirect_url = str(request.base_url)
|
||||
|
||||
if not code:
|
||||
# check if this is a forward from the account linking page
|
||||
@@ -145,55 +168,55 @@ async def keycloak_callback(
|
||||
and error_description == 'authentication_expired'
|
||||
):
|
||||
return RedirectResponse(redirect_url, status_code=302)
|
||||
raise HTTPException(
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Missing code in request params',
|
||||
content={'error': 'Missing code in request params'},
|
||||
)
|
||||
|
||||
web_url = get_web_url(request)
|
||||
redirect_uri = web_url + request.url.path
|
||||
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}{request.url.path}'
|
||||
logger.debug(f'code: {code}, redirect_uri: {redirect_uri}')
|
||||
|
||||
(
|
||||
keycloak_access_token,
|
||||
keycloak_refresh_token,
|
||||
) = await token_manager.get_keycloak_tokens(code, redirect_uri)
|
||||
if not keycloak_access_token or not keycloak_refresh_token:
|
||||
raise HTTPException(
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Problem retrieving Keycloak tokens',
|
||||
content={'error': 'Problem retrieving Keycloak tokens'},
|
||||
)
|
||||
|
||||
user_info = await token_manager.get_user_info(keycloak_access_token)
|
||||
logger.debug(f'user_info: {user_info}')
|
||||
if ROLE_CHECK_ENABLED and user_info.roles is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail='Missing required role'
|
||||
)
|
||||
|
||||
authorization = await user_authorizer.authorize_user(user_info)
|
||||
if not authorization.success:
|
||||
# Return unauthorized
|
||||
raise HTTPException(
|
||||
if ROLE_CHECK_ENABLED and 'roles' not in user_info:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=authorization.error_detail,
|
||||
content={'error': 'Missing required role'},
|
||||
)
|
||||
|
||||
email = user_info.email
|
||||
user_id = user_info.sub
|
||||
user_info_dict = user_info.model_dump(exclude_none=True)
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if 'sub' not in user_info or 'preferred_username' not in user_info:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'error': 'Missing user ID or username in response'},
|
||||
)
|
||||
|
||||
email = user_info.get('email')
|
||||
user_id = user_info['sub']
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
if not user:
|
||||
user = await UserStore.create_user(user_id, user_info_dict)
|
||||
user = await UserStore.create_user(user_id, user_info)
|
||||
else:
|
||||
# Existing user — gradually backfill contact_name if it still has a username-style value
|
||||
await UserStore.backfill_contact_name(user_id, user_info_dict)
|
||||
await UserStore.backfill_user_email(user_id, user_info_dict)
|
||||
await UserStore.backfill_contact_name(user_id, user_info)
|
||||
await UserStore.backfill_user_email(user_id, user_info)
|
||||
|
||||
if not user:
|
||||
logger.error(f'Failed to authenticate user {user_info.email}')
|
||||
raise HTTPException(
|
||||
logger.error(f'Failed to authenticate user {user_info["preferred_username"]}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f'Failed to authenticate user {user_info.email}',
|
||||
content={
|
||||
'error': f'Failed to authenticate user {user_info["preferred_username"]}'
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f'Logging in user {str(user.id)} in org {user.current_org_id}')
|
||||
@@ -208,7 +231,7 @@ async def keycloak_callback(
|
||||
'email': email,
|
||||
},
|
||||
)
|
||||
error_url = f'{web_url}/login?recaptcha_blocked=true'
|
||||
error_url = f'{request.base_url}login?recaptcha_blocked=true'
|
||||
return RedirectResponse(error_url, status_code=302)
|
||||
|
||||
user_ip = request.client.host if request.client else 'unknown'
|
||||
@@ -239,50 +262,74 @@ async def keycloak_callback(
|
||||
},
|
||||
)
|
||||
# Redirect to home with error parameter
|
||||
error_url = f'{web_url}/login?recaptcha_blocked=true'
|
||||
error_url = f'{request.base_url}login?recaptcha_blocked=true'
|
||||
return RedirectResponse(error_url, status_code=302)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'reCAPTCHA verification error at callback: {e}')
|
||||
# Fail open - continue with login if reCAPTCHA service unavailable
|
||||
|
||||
# Check if email domain is blocked
|
||||
if email and domain_blocker.is_domain_blocked(email):
|
||||
logger.warning(
|
||||
f'Blocked authentication attempt for email: {email}, user_id: {user_id}'
|
||||
)
|
||||
|
||||
# Disable the Keycloak account
|
||||
await token_manager.disable_keycloak_user(user_id, email)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={
|
||||
'error': 'Access denied: Your email domain is not allowed to access this service'
|
||||
},
|
||||
)
|
||||
|
||||
# Check for duplicate email with + modifier
|
||||
if email:
|
||||
try:
|
||||
has_duplicate = await token_manager.check_duplicate_base_email(
|
||||
email, user_id
|
||||
)
|
||||
if has_duplicate:
|
||||
logger.warning(
|
||||
f'Blocked signup attempt for email {email} - duplicate base email found',
|
||||
extra={'user_id': user_id, 'email': email},
|
||||
)
|
||||
|
||||
# Delete the Keycloak user that was automatically created during OAuth
|
||||
# This prevents orphaned accounts in Keycloak
|
||||
# The delete_keycloak_user method already handles all errors internally
|
||||
deletion_success = await token_manager.delete_keycloak_user(user_id)
|
||||
if deletion_success:
|
||||
logger.info(
|
||||
f'Deleted Keycloak user {user_id} after detecting duplicate email {email}'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'Failed to delete Keycloak user {user_id} after detecting duplicate email {email}. '
|
||||
f'User may need to be manually cleaned up.'
|
||||
)
|
||||
|
||||
# Redirect to home page with query parameter indicating the issue
|
||||
home_url = f'{request.base_url}/login?duplicated_email=true'
|
||||
return RedirectResponse(home_url, status_code=302)
|
||||
except Exception as e:
|
||||
# Log error but allow signup to proceed (fail open)
|
||||
logger.error(
|
||||
f'Error checking duplicate email for {email}: {e}',
|
||||
extra={'user_id': user_id, 'email': email},
|
||||
)
|
||||
|
||||
# Check email verification status
|
||||
email_verified = user_info.email_verified or False
|
||||
email_verified = user_info.get('email_verified', False)
|
||||
if not email_verified:
|
||||
# Send verification email with rate limiting to prevent abuse
|
||||
# Users who repeatedly login without verifying would otherwise trigger
|
||||
# unlimited verification emails
|
||||
# Send verification email
|
||||
# Import locally to avoid circular import with email.py
|
||||
from server.routes.email import verify_email
|
||||
|
||||
# Rate limit verification emails during auth flow (60 seconds per user)
|
||||
# This is separate from the manual resend rate limit which uses 30 seconds
|
||||
rate_limited = False
|
||||
try:
|
||||
await check_rate_limit_by_user_id(
|
||||
request=request,
|
||||
key_prefix='auth_verify_email',
|
||||
user_id=user_id,
|
||||
user_rate_limit_seconds=60,
|
||||
ip_rate_limit_seconds=120,
|
||||
)
|
||||
await verify_email(request=request, user_id=user_id, is_auth_flow=True)
|
||||
except HTTPException as e:
|
||||
if e.status_code == status.HTTP_429_TOO_MANY_REQUESTS:
|
||||
# Rate limited - still redirect to verification page but don't send email
|
||||
rate_limited = True
|
||||
logger.info(
|
||||
f'Rate limited verification email for user {user_id} during auth flow'
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
verification_redirect_url = (
|
||||
f'{web_url}/login?email_verification_required=true&user_id={user_id}'
|
||||
)
|
||||
if rate_limited:
|
||||
verification_redirect_url = f'{verification_redirect_url}&rate_limited=true'
|
||||
|
||||
await verify_email(request=request, user_id=user_id, is_auth_flow=True)
|
||||
verification_redirect_url = f'{request.base_url}login?email_verification_required=true&user_id={user_id}'
|
||||
# Preserve invitation token so it can be included in OAuth state after verification
|
||||
if invitation_token:
|
||||
verification_redirect_url = (
|
||||
@@ -293,7 +340,7 @@ async def keycloak_callback(
|
||||
|
||||
# default to github IDP for now.
|
||||
# TODO: remove default once Keycloak is updated universally with the new attribute.
|
||||
idp: str = user_info.identity_provider or ProviderType.GITHUB.value
|
||||
idp: str = user_info.get('identity_provider', ProviderType.GITHUB.value)
|
||||
logger.info(f'Full IDP is {idp}')
|
||||
idp_type = 'oidc'
|
||||
if ':' in idp:
|
||||
@@ -304,8 +351,15 @@ async def keycloak_callback(
|
||||
ProviderType(idp), user_id, keycloak_access_token
|
||||
)
|
||||
|
||||
username = user_info['preferred_username']
|
||||
if user_verifier.is_active() and not user_verifier.is_user_allowed(username):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'Not authorized via waitlist'},
|
||||
)
|
||||
|
||||
valid_offline_token = (
|
||||
await token_manager.validate_offline_token(user_id=user_info.sub)
|
||||
await token_manager.validate_offline_token(user_id=user_info['sub'])
|
||||
if idp_type != 'saml'
|
||||
else True
|
||||
)
|
||||
@@ -349,19 +403,13 @@ async def keycloak_callback(
|
||||
)
|
||||
|
||||
if not valid_offline_token:
|
||||
param_str = urlencode(
|
||||
{
|
||||
'client_id': KEYCLOAK_CLIENT_ID,
|
||||
'response_type': 'code',
|
||||
'kc_idp_hint': idp,
|
||||
'redirect_uri': f'{web_url}/oauth/keycloak/offline/callback',
|
||||
'scope': 'openid email profile offline_access',
|
||||
'state': state,
|
||||
}
|
||||
)
|
||||
redirect_url = (
|
||||
f'{KEYCLOAK_SERVER_URL_EXT}/realms/{KEYCLOAK_REALM_NAME}/protocol/openid-connect/auth'
|
||||
f'?{param_str}'
|
||||
f'?client_id={KEYCLOAK_CLIENT_ID}&response_type=code'
|
||||
f'&kc_idp_hint={idp}'
|
||||
f'&redirect_uri={scheme}%3A%2F%2F{request.url.netloc}%2Foauth%2Fkeycloak%2Foffline%2Fcallback'
|
||||
f'&scope=openid%20email%20profile%20offline_access'
|
||||
f'&state={state}'
|
||||
)
|
||||
|
||||
has_accepted_tos = user.accepted_tos is not None
|
||||
@@ -440,7 +488,9 @@ async def keycloak_callback(
|
||||
# If the user hasn't accepted the TOS, redirect to the TOS page
|
||||
if not has_accepted_tos:
|
||||
encoded_redirect_url = quote(redirect_url, safe='')
|
||||
tos_redirect_url = f'{web_url}/accept-tos?redirect_url={encoded_redirect_url}'
|
||||
tos_redirect_url = (
|
||||
f'{request.base_url}accept-tos?redirect_url={encoded_redirect_url}'
|
||||
)
|
||||
if invitation_token:
|
||||
tos_redirect_url = f'{tos_redirect_url}&invitation_success=true'
|
||||
response = RedirectResponse(tos_redirect_url, status_code=302)
|
||||
@@ -454,7 +504,7 @@ async def keycloak_callback(
|
||||
response=response,
|
||||
keycloak_access_token=keycloak_access_token,
|
||||
keycloak_refresh_token=keycloak_refresh_token,
|
||||
secure=True if redirect_url.startswith('https') else False,
|
||||
secure=True if scheme == 'https' else False,
|
||||
accepted_tos=has_accepted_tos,
|
||||
)
|
||||
|
||||
@@ -472,9 +522,10 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'error': 'Missing code in request params'},
|
||||
)
|
||||
|
||||
web_url = get_web_url(request)
|
||||
redirect_uri = web_url + request.url.path
|
||||
scheme = 'https'
|
||||
if request.url.hostname == 'localhost':
|
||||
scheme = 'http'
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}{request.url.path}'
|
||||
logger.debug(f'code: {code}, redirect_uri: {redirect_uri}')
|
||||
|
||||
(
|
||||
@@ -489,21 +540,26 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
|
||||
|
||||
user_info = await token_manager.get_user_info(keycloak_access_token)
|
||||
logger.debug(f'user_info: {user_info}')
|
||||
# sub is a required field in KeycloakUserInfo, validation happens in get_user_info
|
||||
if 'sub' not in user_info:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'error': 'Missing Keycloak ID in response'},
|
||||
)
|
||||
|
||||
await token_manager.store_offline_token(
|
||||
user_id=user_info.sub, offline_token=keycloak_refresh_token
|
||||
user_id=user_info['sub'], offline_token=keycloak_refresh_token
|
||||
)
|
||||
|
||||
redirect_url, _, _ = _extract_oauth_state(state)
|
||||
return RedirectResponse(redirect_url if redirect_url else web_url, status_code=302)
|
||||
return RedirectResponse(
|
||||
redirect_url if redirect_url else request.base_url, status_code=302
|
||||
)
|
||||
|
||||
|
||||
@oauth_router.get('/github/callback')
|
||||
async def github_dummy_callback(request: Request):
|
||||
"""Callback for GitHub that just forwards the user to the app base URL."""
|
||||
web_url = get_web_url(request)
|
||||
return RedirectResponse(web_url, status_code=302)
|
||||
return RedirectResponse(request.base_url, status_code=302)
|
||||
|
||||
|
||||
@api_router.post('/authenticate')
|
||||
@@ -525,8 +581,8 @@ async def authenticate(request: Request):
|
||||
if keycloak_auth_cookie:
|
||||
response.delete_cookie(
|
||||
key='keycloak_auth',
|
||||
domain=get_cookie_domain(),
|
||||
samesite=get_cookie_samesite(),
|
||||
domain=get_cookie_domain(request),
|
||||
samesite=get_cookie_samesite(request),
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -534,7 +590,7 @@ async def authenticate(request: Request):
|
||||
|
||||
@api_router.post('/accept_tos')
|
||||
async def accept_tos(request: Request):
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
access_token = await user_auth.get_access_token()
|
||||
refresh_token = user_auth.refresh_token
|
||||
user_id = await user_auth.get_user_id()
|
||||
@@ -550,25 +606,21 @@ async def accept_tos(request: Request):
|
||||
|
||||
# Get redirect URL from request body
|
||||
body = await request.json()
|
||||
web_url = get_web_url(request)
|
||||
redirect_url = body.get('redirect_url', str(web_url))
|
||||
redirect_url = body.get('redirect_url', str(request.base_url))
|
||||
|
||||
# Update user settings with TOS acceptance
|
||||
accepted_tos: datetime = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.id == uuid.UUID(user_id))
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
accepted_tos: datetime = datetime.now(timezone.utc)
|
||||
with session_maker() as session:
|
||||
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
|
||||
if not user:
|
||||
await session.rollback()
|
||||
session.rollback()
|
||||
logger.error('User for {user_id} not found.')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'User does not exist'},
|
||||
)
|
||||
user.accepted_tos = accepted_tos
|
||||
await session.commit()
|
||||
session.commit()
|
||||
|
||||
logger.info(f'User {user_id} accepted TOS')
|
||||
|
||||
@@ -581,7 +633,7 @@ async def accept_tos(request: Request):
|
||||
response=response,
|
||||
keycloak_access_token=access_token.get_secret_value(),
|
||||
keycloak_refresh_token=refresh_token.get_secret_value(),
|
||||
secure=not IS_LOCAL_ENV,
|
||||
secure=False if request.url.hostname == 'localhost' else True,
|
||||
accepted_tos=True,
|
||||
)
|
||||
return response
|
||||
@@ -598,13 +650,13 @@ async def logout(request: Request):
|
||||
# Always delete the cookie regardless of what happens
|
||||
response.delete_cookie(
|
||||
key='keycloak_auth',
|
||||
domain=get_cookie_domain(),
|
||||
samesite=get_cookie_samesite(),
|
||||
domain=get_cookie_domain(request),
|
||||
samesite=get_cookie_samesite(request),
|
||||
)
|
||||
|
||||
# Try to properly logout from Keycloak, but don't fail if it doesn't work
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
if user_auth and user_auth.refresh_token:
|
||||
refresh_token = user_auth.refresh_token.get_secret_value()
|
||||
await token_manager.logout(refresh_token)
|
||||
|
||||
@@ -11,10 +11,9 @@ from integrations import stripe_service
|
||||
from pydantic import BaseModel
|
||||
from server.constants import STRIPE_API_KEY
|
||||
from server.logger import logger
|
||||
from server.utils.url_utils import get_web_url
|
||||
from sqlalchemy import select
|
||||
from starlette.datastructures import URL
|
||||
from storage.billing_session import BillingSession
|
||||
from storage.database import a_session_maker
|
||||
from storage.database import session_maker
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.org import Org
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
@@ -90,9 +89,7 @@ def calculate_credits(user_info: LiteLlmUserInfo) -> float:
|
||||
async def get_credits(user_id: str = Depends(get_user_id)) -> GetCreditsResponse:
|
||||
if not stripe_service.STRIPE_API_KEY:
|
||||
return GetCreditsResponse()
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
user_team_info = await LiteLlmManager.get_user_team_info(
|
||||
user_id, str(user.current_org_id)
|
||||
)
|
||||
@@ -109,17 +106,16 @@ async def get_subscription_access(
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> SubscriptionAccessResponse | None:
|
||||
"""Get details of the currently valid subscription for the user."""
|
||||
async with a_session_maker() as session:
|
||||
with session_maker() as session:
|
||||
now = datetime.now(UTC)
|
||||
result = await session.execute(
|
||||
select(SubscriptionAccess).where(
|
||||
SubscriptionAccess.status == 'ACTIVE',
|
||||
SubscriptionAccess.user_id == user_id,
|
||||
SubscriptionAccess.start_at <= now,
|
||||
SubscriptionAccess.end_at >= now,
|
||||
)
|
||||
subscription_access = (
|
||||
session.query(SubscriptionAccess)
|
||||
.filter(SubscriptionAccess.status == 'ACTIVE')
|
||||
.filter(SubscriptionAccess.user_id == user_id)
|
||||
.filter(SubscriptionAccess.start_at <= now)
|
||||
.filter(SubscriptionAccess.end_at >= now)
|
||||
.first()
|
||||
)
|
||||
subscription_access = result.scalar_one_or_none()
|
||||
if not subscription_access:
|
||||
return None
|
||||
return SubscriptionAccessResponse(
|
||||
@@ -146,12 +142,7 @@ async def create_customer_setup_session(
|
||||
) -> CreateBillingSessionResponse:
|
||||
await validate_billing_enabled()
|
||||
customer_info = await stripe_service.find_or_create_customer_by_user_id(user_id)
|
||||
if not customer_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Could not find or create customer for user',
|
||||
)
|
||||
base_url = get_web_url(request)
|
||||
base_url = _get_base_url(request)
|
||||
checkout_session = await stripe.checkout.Session.create_async(
|
||||
customer=customer_info['customer_id'],
|
||||
mode='setup',
|
||||
@@ -170,13 +161,8 @@ async def create_checkout_session(
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> CreateBillingSessionResponse:
|
||||
await validate_billing_enabled()
|
||||
base_url = get_web_url(request)
|
||||
base_url = _get_base_url(request)
|
||||
customer_info = await stripe_service.find_or_create_customer_by_user_id(user_id)
|
||||
if not customer_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Could not find or create customer for user',
|
||||
)
|
||||
checkout_session = await stripe.checkout.Session.create_async(
|
||||
customer=customer_info['customer_id'],
|
||||
line_items=[
|
||||
@@ -198,8 +184,8 @@ async def create_checkout_session(
|
||||
saved_payment_method_options={
|
||||
'payment_method_save': 'enabled',
|
||||
},
|
||||
success_url=f'{base_url}/api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
cancel_url=f'{base_url}/api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
success_url=f'{base_url}api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
cancel_url=f'{base_url}api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
)
|
||||
logger.info(
|
||||
'created_stripe_checkout_session',
|
||||
@@ -211,7 +197,7 @@ async def create_checkout_session(
|
||||
'checkout_session_id': checkout_session.id,
|
||||
},
|
||||
)
|
||||
async with a_session_maker() as session:
|
||||
with session_maker() as session:
|
||||
billing_session = BillingSession(
|
||||
id=checkout_session.id,
|
||||
user_id=user_id,
|
||||
@@ -220,7 +206,7 @@ async def create_checkout_session(
|
||||
price_code='NA',
|
||||
)
|
||||
session.add(billing_session)
|
||||
await session.commit()
|
||||
session.commit()
|
||||
|
||||
return CreateBillingSessionResponse(redirect_url=checkout_session.url)
|
||||
|
||||
@@ -229,14 +215,13 @@ async def create_checkout_session(
|
||||
@billing_router.get('/success')
|
||||
async def success_callback(session_id: str, request: Request):
|
||||
# We can't use the auth cookie because of SameSite=strict
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(BillingSession).where(
|
||||
BillingSession.id == session_id,
|
||||
BillingSession.status == 'in_progress',
|
||||
)
|
||||
with session_maker() as session:
|
||||
billing_session = (
|
||||
session.query(BillingSession)
|
||||
.filter(BillingSession.id == session_id)
|
||||
.filter(BillingSession.status == 'in_progress')
|
||||
.first()
|
||||
)
|
||||
billing_session = result.scalar_one_or_none()
|
||||
|
||||
if billing_session is None:
|
||||
# Hopefully this never happens - we get a redirect from stripe where the session does not exist
|
||||
@@ -258,9 +243,7 @@ async def success_callback(session_id: str, request: Request):
|
||||
)
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user = await UserStore.get_user_by_id(billing_session.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
user = await UserStore.get_user_by_id_async(billing_session.user_id)
|
||||
user_team_info = await LiteLlmManager.get_user_team_info(
|
||||
billing_session.user_id, str(user.current_org_id)
|
||||
)
|
||||
@@ -270,8 +253,7 @@ async def success_callback(session_id: str, request: Request):
|
||||
user_team_info, billing_session.user_id, str(user.current_org_id)
|
||||
)
|
||||
|
||||
result = await session.execute(select(Org).where(Org.id == user.current_org_id))
|
||||
org = result.scalar_one_or_none()
|
||||
org = session.query(Org).filter(Org.id == user.current_org_id).first()
|
||||
new_max_budget = max_budget + add_credits
|
||||
|
||||
await LiteLlmManager.update_team_and_users_budget(
|
||||
@@ -297,24 +279,23 @@ async def success_callback(session_id: str, request: Request):
|
||||
'stripe_customer_id': stripe_session.customer,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
session.commit()
|
||||
|
||||
return RedirectResponse(
|
||||
f'{get_web_url(request)}/settings/billing?checkout=success', status_code=302
|
||||
f'{_get_base_url(request)}settings/billing?checkout=success', status_code=302
|
||||
)
|
||||
|
||||
|
||||
# Callback endpoint for cancelled Stripe payments - updates billing session status
|
||||
@billing_router.get('/cancel')
|
||||
async def cancel_callback(session_id: str, request: Request):
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(BillingSession).where(
|
||||
BillingSession.id == session_id,
|
||||
BillingSession.status == 'in_progress',
|
||||
)
|
||||
with session_maker() as session:
|
||||
billing_session = (
|
||||
session.query(BillingSession)
|
||||
.filter(BillingSession.id == session_id)
|
||||
.filter(BillingSession.status == 'in_progress')
|
||||
.first()
|
||||
)
|
||||
billing_session = result.scalar_one_or_none()
|
||||
if billing_session:
|
||||
logger.info(
|
||||
'stripe_checkout_cancel',
|
||||
@@ -325,9 +306,17 @@ async def cancel_callback(session_id: str, request: Request):
|
||||
)
|
||||
billing_session.status = 'cancelled'
|
||||
billing_session.updated_at = datetime.now(UTC)
|
||||
await session.merge(billing_session)
|
||||
await session.commit()
|
||||
session.merge(billing_session)
|
||||
session.commit()
|
||||
|
||||
return RedirectResponse(
|
||||
f'{get_web_url(request)}/settings/billing?checkout=cancel', status_code=302
|
||||
f'{_get_base_url(request)}settings/billing?checkout=cancel', status_code=302
|
||||
)
|
||||
|
||||
|
||||
def _get_base_url(request: Request) -> URL:
|
||||
# Never send any part of the credit card process over a non secure connection
|
||||
base_url = request.base_url
|
||||
if base_url.hostname != 'localhost':
|
||||
base_url = base_url.replace(scheme='https')
|
||||
return base_url
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from server.auth.constants import BITBUCKET_DATA_CENTER_HOST
|
||||
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
router = APIRouter(prefix='/bitbucket-dc-proxy')
|
||||
|
||||
BITBUCKET_DC_TIMEOUT = 10 # seconds
|
||||
|
||||
|
||||
# Bitbucket Data Center is not an OIDC provider, so keycloak
|
||||
# can't retrieve user info from it directly.
|
||||
# This endpoint proxies requests to bitbucket data center to get user info
|
||||
# given a Bitbucket Data Center access token. Keycloak
|
||||
# is configured to use this endpoint as the User Info Endpoint
|
||||
# for the Bitbucket Data Center OIDC provider.
|
||||
@router.get('/oauth2/userinfo')
|
||||
async def userinfo(request: Request):
|
||||
if not BITBUCKET_DATA_CENTER_HOST:
|
||||
raise ValueError('BITBUCKET_DATA_CENTER_HOST must be configured')
|
||||
bitbucket_base_url = f'https://{BITBUCKET_DATA_CENTER_HOST}'
|
||||
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return JSONResponse({'error': 'missing_token'}, status_code=401)
|
||||
|
||||
headers = {'Authorization': auth_header}
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
# Step 1: get username
|
||||
whoami_resp = await client.get(
|
||||
f'{bitbucket_base_url}/plugins/servlet/applinks/whoami',
|
||||
headers=headers,
|
||||
timeout=BITBUCKET_DC_TIMEOUT,
|
||||
)
|
||||
if whoami_resp.status_code != 200:
|
||||
return JSONResponse({'error': 'not_authenticated'}, status_code=401)
|
||||
username = whoami_resp.text.strip()
|
||||
if not username:
|
||||
return JSONResponse({'error': 'not_authenticated'}, status_code=401)
|
||||
|
||||
# Step 2: get user details
|
||||
user_resp = await client.get(
|
||||
f'{bitbucket_base_url}/rest/api/latest/users/{username}',
|
||||
headers=headers,
|
||||
timeout=BITBUCKET_DC_TIMEOUT,
|
||||
)
|
||||
if user_resp.status_code != 200:
|
||||
return JSONResponse(
|
||||
{'error': f'bitbucket_error: {user_resp.status_code}'},
|
||||
status_code=user_resp.status_code,
|
||||
)
|
||||
user_data = user_resp.json()
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
'sub': str(user_data.get('id', username)),
|
||||
'preferred_username': user_data.get('name', username),
|
||||
'name': user_data.get('displayName', username),
|
||||
'email': user_data.get('emailAddress', ''),
|
||||
}
|
||||
)
|
||||
163
enterprise/server/routes/debugging.py
Normal file
163
enterprise/server/routes/debugging.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from threading import Thread
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from sqlalchemy import func, select
|
||||
from storage.database import a_session_maker, get_engine, session_maker
|
||||
from storage.user import User
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.async_utils import wait_all
|
||||
|
||||
# Safety flag to prevent chaos routes from being added in production environments
|
||||
# Only enables these routes in non-production environments
|
||||
ADD_DEBUGGING_ROUTES = os.environ.get('ADD_DEBUGGING_ROUTES') in ('1', 'true')
|
||||
|
||||
|
||||
def add_debugging_routes(api: FastAPI):
|
||||
"""
|
||||
# HERE BE DRAGONS!
|
||||
Chaos scripts for debugging and stress testing the system.
|
||||
|
||||
This module contains endpoints that deliberately stress test and potentially break
|
||||
the system to help identify weaknesses and bottlenecks. It includes a safety check
|
||||
to ensure these routes are never deployed to production environments.
|
||||
|
||||
The routes in this module are specifically designed for:
|
||||
- Testing connection pool behavior under load
|
||||
- Simulating database connection exhaustion
|
||||
- Testing async vs sync database access patterns
|
||||
- Simulating event loop blocking
|
||||
"""
|
||||
|
||||
if not ADD_DEBUGGING_ROUTES:
|
||||
return
|
||||
|
||||
chaos_router = APIRouter(prefix='/debugging')
|
||||
|
||||
@chaos_router.get('/pool-stats')
|
||||
def pool_stats() -> dict[str, int]:
|
||||
"""
|
||||
Returns current database connection pool statistics.
|
||||
|
||||
This endpoint provides real-time metrics about the SQLAlchemy connection pool:
|
||||
- checked_in: Number of connections currently available in the pool
|
||||
- checked_out: Number of connections currently in use
|
||||
- overflow: Number of overflow connections created beyond pool_size
|
||||
"""
|
||||
engine = get_engine()
|
||||
return {
|
||||
'checked_in': engine.pool.checkedin(),
|
||||
'checked_out': engine.pool.checkedout(),
|
||||
'overflow': engine.pool.overflow(),
|
||||
}
|
||||
|
||||
@chaos_router.get('/test-db')
|
||||
def test_db(num_tests: int = 10, delay: int = 1) -> str:
|
||||
"""
|
||||
Stress tests the database connection pool using multiple threads.
|
||||
|
||||
Creates multiple threads that each open a database connection, perform a query,
|
||||
hold the connection for the specified delay, and then release it.
|
||||
|
||||
Parameters:
|
||||
num_tests: Number of concurrent database connections to create
|
||||
delay: Number of seconds each connection is held open
|
||||
|
||||
This test helps identify connection pool exhaustion issues and connection
|
||||
leaks under concurrent load.
|
||||
"""
|
||||
threads = [Thread(target=_db_check, args=(delay,)) for _ in range(num_tests)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
return 'success'
|
||||
|
||||
@chaos_router.get('/a-test-db')
|
||||
async def a_chaos_monkey(num_tests: int = 10, delay: int = 1) -> str:
|
||||
"""
|
||||
Stress tests the async database connection pool.
|
||||
|
||||
Similar to /test-db but uses async connections and coroutines instead of threads.
|
||||
This endpoint helps compare the behavior of async vs sync connection pools
|
||||
under similar load conditions.
|
||||
|
||||
Parameters:
|
||||
num_tests: Number of concurrent async database connections to create
|
||||
delay: Number of seconds each connection is held open
|
||||
"""
|
||||
await wait_all((_a_db_check(delay) for _ in range(num_tests)))
|
||||
return 'success'
|
||||
|
||||
@chaos_router.get('/lock-main-runloop')
|
||||
async def lock_main_runloop(duration: int = 10) -> str:
|
||||
"""
|
||||
Deliberately blocks the main asyncio event loop.
|
||||
|
||||
This endpoint uses a synchronous sleep operation in an async function,
|
||||
which blocks the entire FastAPI server's event loop for the specified duration.
|
||||
This simulates what happens when CPU-intensive operations or blocking I/O
|
||||
operations are incorrectly used in async code.
|
||||
|
||||
Parameters:
|
||||
duration: Number of seconds to block the event loop
|
||||
|
||||
WARNING: This will make the entire server unresponsive for the duration!
|
||||
"""
|
||||
time.sleep(duration)
|
||||
return 'success'
|
||||
|
||||
api.include_router(chaos_router) # Add routes for readiness checks
|
||||
|
||||
|
||||
def _db_check(delay: int):
|
||||
"""
|
||||
Executes a single request against the database with an artificial delay.
|
||||
|
||||
This helper function:
|
||||
1. Opens a database connection from the pool
|
||||
2. Executes a simple query to count users
|
||||
3. Holds the connection for the specified delay
|
||||
4. Logs connection pool statistics
|
||||
5. Implicitly returns the connection to the pool when the session closes
|
||||
|
||||
Args:
|
||||
delay: Number of seconds to hold the database connection
|
||||
"""
|
||||
with session_maker() as session:
|
||||
num_users = session.query(User).count()
|
||||
time.sleep(delay)
|
||||
engine = get_engine()
|
||||
logger.info(
|
||||
'check',
|
||||
extra={
|
||||
'num_users': num_users,
|
||||
'checked_in': engine.pool.checkedin(),
|
||||
'checked_out': engine.pool.checkedout(),
|
||||
'overflow': engine.pool.overflow(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _a_db_check(delay: int):
|
||||
"""
|
||||
Executes a single async request against the database with an artificial delay.
|
||||
|
||||
This is the async version of _db_check that:
|
||||
1. Opens an async database connection from the pool
|
||||
2. Executes a simple query to count users using SQLAlchemy's async API
|
||||
3. Holds the connection for the specified delay using asyncio.sleep
|
||||
4. Logs the results
|
||||
5. Implicitly returns the connection to the pool when the async session closes
|
||||
|
||||
Args:
|
||||
delay: Number of seconds to hold the database connection
|
||||
"""
|
||||
async with a_session_maker() as a_session:
|
||||
stmt = select(func.count(User.id))
|
||||
num_users = await a_session.execute(stmt)
|
||||
await asyncio.sleep(delay)
|
||||
logger.info(f'a_num_users:{num_users.scalar_one()}')
|
||||
@@ -1,5 +1,4 @@
|
||||
import re
|
||||
from typing import cast
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
@@ -7,10 +6,8 @@ from pydantic import BaseModel, field_validator
|
||||
from server.auth.constants import KEYCLOAK_CLIENT_ID
|
||||
from server.auth.keycloak_manager import get_keycloak_admin
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.constants import IS_LOCAL_ENV
|
||||
from server.routes.auth import set_response_cookie
|
||||
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from server.utils.url_utils import get_web_url
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -70,7 +67,7 @@ async def update_email(
|
||||
user_id=user_id, email=email, email_verified=False
|
||||
)
|
||||
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
await user_auth.refresh() # refresh so access token has updated email
|
||||
user_auth.email = email
|
||||
user_auth.email_verified = False
|
||||
@@ -79,18 +76,13 @@ async def update_email(
|
||||
)
|
||||
|
||||
# need to set auth cookie to the new tokens
|
||||
if user_auth.access_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Access token not found',
|
||||
)
|
||||
set_response_cookie(
|
||||
request=request,
|
||||
response=response,
|
||||
keycloak_access_token=user_auth.access_token.get_secret_value(),
|
||||
keycloak_refresh_token=user_auth.refresh_token.get_secret_value(),
|
||||
secure=not IS_LOCAL_ENV,
|
||||
accepted_tos=user_auth.accepted_tos or False,
|
||||
secure=False if request.url.hostname == 'localhost' else True,
|
||||
accepted_tos=user_auth.accepted_tos,
|
||||
)
|
||||
|
||||
await verify_email(request=request, user_id=user_id)
|
||||
@@ -154,26 +146,22 @@ async def resend_email_verification(
|
||||
|
||||
@api_router.get('/verified')
|
||||
async def verified_email(request: Request):
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
await user_auth.refresh() # refresh so access token has updated email
|
||||
user_auth.email_verified = True
|
||||
await UserStore.update_user_email(user_id=user_auth.user_id, email_verified=True)
|
||||
|
||||
redirect_uri = f'{get_web_url(request)}/settings/user'
|
||||
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}/settings/user'
|
||||
response = RedirectResponse(redirect_uri, status_code=302)
|
||||
|
||||
# need to set auth cookie to the new tokens
|
||||
if user_auth.access_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail='Access token not found'
|
||||
)
|
||||
set_response_cookie(
|
||||
request=request,
|
||||
response=response,
|
||||
keycloak_access_token=user_auth.access_token.get_secret_value(),
|
||||
keycloak_refresh_token=user_auth.refresh_token.get_secret_value(),
|
||||
secure=False if request.url.hostname == 'localhost' else True,
|
||||
accepted_tos=user_auth.accepted_tos or False,
|
||||
accepted_tos=user_auth.accepted_tos,
|
||||
)
|
||||
|
||||
logger.info(f'Email {user_auth.email} verified.')
|
||||
@@ -182,10 +170,11 @@ async def verified_email(request: Request):
|
||||
|
||||
async def verify_email(request: Request, user_id: str, is_auth_flow: bool = False):
|
||||
keycloak_admin = get_keycloak_admin()
|
||||
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
|
||||
if is_auth_flow:
|
||||
redirect_uri = f'{get_web_url(request)}/login?email_verified=true'
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}/login?email_verified=true'
|
||||
else:
|
||||
redirect_uri = f'{get_web_url(request)}/api/email/verified'
|
||||
redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified'
|
||||
logger.info(f'Redirect URI: {redirect_uri}')
|
||||
await keycloak_admin.a_send_verify_email(
|
||||
user_id=user_id,
|
||||
|
||||
@@ -93,16 +93,6 @@ async def _process_batch_operations_background(
|
||||
)
|
||||
continue # Skip this operation but continue with others
|
||||
|
||||
if user_id is None:
|
||||
logger.error(
|
||||
'user_id_not_set_in_batch_webhook',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'path': batch_op.path,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if subpath == 'agent_state.pkl':
|
||||
update_agent_state(user_id, conversation_id, batch_op.get_content())
|
||||
continue
|
||||
@@ -129,6 +119,10 @@ async def _process_batch_operations_background(
|
||||
# No action required
|
||||
continue
|
||||
|
||||
if subpath == 'exp_config.json':
|
||||
# No action required
|
||||
continue
|
||||
|
||||
# Log unhandled paths for future implementation
|
||||
logger.warning(
|
||||
'unknown_path_in_batch_webhook',
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Any, Dict, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.future import select
|
||||
from storage.database import a_session_maker
|
||||
from storage.database import session_maker
|
||||
from storage.feedback import ConversationFeedback
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
|
||||
# is protected. The actual protection is provided by SetAuthCookieMiddleware
|
||||
@@ -36,19 +37,23 @@ async def get_event_ids(conversation_id: str, user_id: str) -> List[int]:
|
||||
"""
|
||||
|
||||
# Verify the conversation belongs to the user
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == conversation_id,
|
||||
StoredConversationMetadataSaas.user_id == user_id,
|
||||
)
|
||||
)
|
||||
metadata = result.scalars().first()
|
||||
if not metadata:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Conversation {conversation_id} not found',
|
||||
def _verify_conversation():
|
||||
with session_maker() as session:
|
||||
metadata = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(
|
||||
StoredConversationMetadataSaas.conversation_id == conversation_id,
|
||||
StoredConversationMetadataSaas.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not metadata:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Conversation {conversation_id} not found',
|
||||
)
|
||||
|
||||
await call_sync_from_async(_verify_conversation)
|
||||
|
||||
# Create an event store to access the events directly
|
||||
# This works even when the conversation is not running
|
||||
@@ -98,9 +103,12 @@ async def submit_conversation_feedback(feedback: FeedbackRequest):
|
||||
)
|
||||
|
||||
# Add to database
|
||||
async with a_session_maker() as session:
|
||||
session.add(new_feedback)
|
||||
await session.commit()
|
||||
def _save_feedback():
|
||||
with session_maker() as session:
|
||||
session.add(new_feedback)
|
||||
session.commit()
|
||||
|
||||
await call_sync_from_async(_save_feedback)
|
||||
|
||||
return {'status': 'success', 'message': 'Feedback submitted successfully'}
|
||||
|
||||
@@ -119,27 +127,30 @@ async def get_batch_feedback(conversation_id: str, user_id: str = Depends(get_us
|
||||
return {}
|
||||
|
||||
# Query for existing feedback for all events
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ConversationFeedback).where(
|
||||
ConversationFeedback.conversation_id == conversation_id,
|
||||
ConversationFeedback.event_id.in_(event_ids),
|
||||
def _check_feedback():
|
||||
with session_maker() as session:
|
||||
result = session.execute(
|
||||
select(ConversationFeedback).where(
|
||||
ConversationFeedback.conversation_id == conversation_id,
|
||||
ConversationFeedback.event_id.in_(event_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Create a mapping of event_id to feedback
|
||||
feedback_map = {
|
||||
feedback.event_id: {
|
||||
'exists': True,
|
||||
'rating': feedback.rating,
|
||||
'reason': feedback.reason,
|
||||
# Create a mapping of event_id to feedback
|
||||
feedback_map = {
|
||||
feedback.event_id: {
|
||||
'exists': True,
|
||||
'rating': feedback.rating,
|
||||
'reason': feedback.reason,
|
||||
}
|
||||
for feedback in result.scalars()
|
||||
}
|
||||
for feedback in result.scalars()
|
||||
}
|
||||
|
||||
# Build response including all events
|
||||
response = {}
|
||||
for event_id in event_ids:
|
||||
response[str(event_id)] = feedback_map.get(event_id, {'exists': False})
|
||||
# Build response including all events
|
||||
response = {}
|
||||
for event_id in event_ids:
|
||||
response[str(event_id)] = feedback_map.get(event_id, {'exists': False})
|
||||
|
||||
return response
|
||||
return response
|
||||
|
||||
return await call_sync_from_async(_check_feedback)
|
||||
|
||||
@@ -13,7 +13,7 @@ from integrations.gitlab.webhook_installation import (
|
||||
)
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL, IS_LOCAL_DEPLOYMENT
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from pydantic import BaseModel
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
@@ -68,14 +68,11 @@ async def verify_gitlab_signature(
|
||||
if not header_webhook_secret or not webhook_uuid or not user_id:
|
||||
raise HTTPException(status_code=403, detail='Required payload headers missing!')
|
||||
|
||||
if IS_LOCAL_DEPLOYMENT:
|
||||
webhook_secret: str | None = 'localdeploymentwebhooktesttoken'
|
||||
else:
|
||||
webhook_secret = await webhook_store.get_webhook_secret(
|
||||
webhook_uuid=webhook_uuid, user_id=user_id
|
||||
)
|
||||
webhook_secret = await webhook_store.get_webhook_secret(
|
||||
webhook_uuid=webhook_uuid, user_id=user_id
|
||||
)
|
||||
|
||||
if not webhook_secret or header_webhook_secret != webhook_secret:
|
||||
if header_webhook_secret != webhook_secret:
|
||||
raise HTTPException(status_code=403, detail="Request signatures didn't match!")
|
||||
|
||||
|
||||
@@ -332,12 +329,6 @@ async def reinstall_gitlab_webhook(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
if not webhook:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create or fetch webhook record',
|
||||
)
|
||||
|
||||
# Verify conditions and install webhook
|
||||
try:
|
||||
await verify_webhook_conditions(
|
||||
|
||||
@@ -4,7 +4,6 @@ import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from typing import cast
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import requests
|
||||
@@ -309,11 +308,10 @@ async def jira_events(
|
||||
logger.info(f'Processing new Jira webhook event: {signature}')
|
||||
redis_client.setex(key, 300, '1')
|
||||
|
||||
# Process the webhook in background after returning response.
|
||||
# Note: For async functions, BackgroundTasks runs them in the same event loop
|
||||
# (not a thread pool), so asyncpg connections work correctly.
|
||||
# Process the webhook
|
||||
message_payload = {'payload': payload}
|
||||
message = Message(source=SourceType.JIRA, message=message_payload)
|
||||
|
||||
background_tasks.add_task(jira_manager.receive_message, message)
|
||||
|
||||
return JSONResponse({'success': True})
|
||||
@@ -333,7 +331,7 @@ async def jira_events(
|
||||
async def create_jira_workspace(request: Request, workspace_data: JiraWorkspaceCreate):
|
||||
"""Create a new Jira workspace registration."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
user_email = await user_auth.get_user_email()
|
||||
|
||||
@@ -397,7 +395,7 @@ async def create_jira_workspace(request: Request, workspace_data: JiraWorkspaceC
|
||||
async def create_workspace_link(request: Request, link_data: JiraLinkCreate):
|
||||
"""Register a user mapping to a Jira workspace."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
user_email = await user_auth.get_user_email()
|
||||
|
||||
@@ -598,15 +596,9 @@ async def jira_callback(request: Request, code: str, state: str):
|
||||
async def get_current_workspace_link(request: Request):
|
||||
"""Get current user's Jira integration details."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User ID not found',
|
||||
)
|
||||
|
||||
user = await jira_manager.integration_store.get_user_by_active_workspace(
|
||||
user_id
|
||||
)
|
||||
@@ -657,15 +649,9 @@ async def get_current_workspace_link(request: Request):
|
||||
async def unlink_workspace(request: Request):
|
||||
"""Unlink user from Jira integration by setting status to inactive."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User ID not found',
|
||||
)
|
||||
|
||||
user = await jira_manager.integration_store.get_user_by_active_workspace(
|
||||
user_id
|
||||
)
|
||||
@@ -719,7 +705,7 @@ async def validate_workspace_integration(request: Request, workspace_name: str):
|
||||
detail='workspace_name can only contain alphanumeric characters, hyphens, underscores, and periods',
|
||||
)
|
||||
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_email = await user_auth.get_user_email()
|
||||
if not user_email:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from typing import cast
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import requests
|
||||
@@ -277,16 +276,10 @@ async def create_jira_dc_workspace(
|
||||
):
|
||||
"""Create a new Jira DC workspace registration."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
user_email = await user_auth.get_user_email()
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User ID not found',
|
||||
)
|
||||
|
||||
if JIRA_DC_ENABLE_OAUTH:
|
||||
# OAuth flow enabled - create session and redirect to OAuth
|
||||
state = str(uuid.uuid4())
|
||||
@@ -406,16 +399,10 @@ async def create_jira_dc_workspace(
|
||||
async def create_workspace_link(request: Request, link_data: JiraDcLinkCreate):
|
||||
"""Register a user mapping to a Jira DC workspace."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
user_email = await user_auth.get_user_email()
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User ID not found',
|
||||
)
|
||||
|
||||
target_workspace = link_data.workspace_name
|
||||
|
||||
if JIRA_DC_ENABLE_OAUTH:
|
||||
@@ -602,15 +589,9 @@ async def jira_dc_callback(request: Request, code: str, state: str):
|
||||
async def get_current_workspace_link(request: Request):
|
||||
"""Get current user's Jira DC integration details."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User ID not found',
|
||||
)
|
||||
|
||||
user = await jira_dc_manager.integration_store.get_user_by_active_workspace(
|
||||
user_id
|
||||
)
|
||||
@@ -660,15 +641,9 @@ async def get_current_workspace_link(request: Request):
|
||||
async def unlink_workspace(request: Request):
|
||||
"""Unlink user from Jira DC integration by setting status to inactive."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User ID not found',
|
||||
)
|
||||
|
||||
user = await jira_dc_manager.integration_store.get_user_by_active_workspace(
|
||||
user_id
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from typing import cast
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
|
||||
@@ -270,7 +269,7 @@ async def create_linear_workspace(
|
||||
):
|
||||
"""Create a new Linear workspace registration."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
user_email = await user_auth.get_user_email()
|
||||
|
||||
@@ -332,7 +331,7 @@ async def create_linear_workspace(
|
||||
async def create_workspace_link(request: Request, link_data: LinearLinkCreate):
|
||||
"""Register a user mapping to a Linear workspace."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
user_email = await user_auth.get_user_email()
|
||||
|
||||
@@ -521,13 +520,8 @@ async def linear_callback(request: Request, code: str, state: str):
|
||||
async def get_current_workspace_link(request: Request):
|
||||
"""Get current user's Linear integration details."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User not authenticated',
|
||||
)
|
||||
|
||||
user = await linear_manager.integration_store.get_user_by_active_workspace(
|
||||
user_id
|
||||
@@ -579,13 +573,8 @@ async def get_current_workspace_link(request: Request):
|
||||
async def unlink_workspace(request: Request):
|
||||
"""Unlink user from Linear integration by setting status to inactive."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User not authenticated',
|
||||
)
|
||||
|
||||
user = await linear_manager.integration_store.get_user_by_active_workspace(
|
||||
user_id
|
||||
@@ -640,7 +629,7 @@ async def validate_workspace_integration(request: Request, workspace_name: str):
|
||||
detail='workspace_name can only contain alphanumeric characters, hyphens, underscores, and periods',
|
||||
)
|
||||
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_auth: SaasUserAuth = await get_user_auth(request)
|
||||
user_email = await user_auth.get_user_email()
|
||||
if not user_email:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -11,7 +11,6 @@ from fastapi.responses import (
|
||||
RedirectResponse,
|
||||
)
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.slack.slack_errors import SlackError, SlackErrorCode
|
||||
from integrations.slack.slack_manager import SlackManager
|
||||
from integrations.utils import (
|
||||
HOST_URL,
|
||||
@@ -32,13 +31,12 @@ from server.logger import logger
|
||||
from slack_sdk.oauth import AuthorizeUrlGenerator
|
||||
from slack_sdk.signature import SignatureVerifier
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
from sqlalchemy import delete
|
||||
from storage.database import a_session_maker
|
||||
from storage.database import session_maker
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
from storage.slack_user import SlackUser
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.integrations.service_types import ProviderTimeoutError, ProviderType
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.shared import config, sio
|
||||
|
||||
signature_verifier = SignatureVerifier(signing_secret=SLACK_SIGNING_SECRET)
|
||||
@@ -172,7 +170,7 @@ async def keycloak_callback(
|
||||
state, config.jwt_secret.get_secret_value(), algorithms=['HS256']
|
||||
)
|
||||
slack_user_id = payload['slack_user_id']
|
||||
bot_access_token: str | None = payload['bot_access_token']
|
||||
bot_access_token = payload['bot_access_token']
|
||||
team_id = payload['team_id']
|
||||
|
||||
# Retrieve the keycloak_user_id
|
||||
@@ -197,8 +195,8 @@ async def keycloak_callback(
|
||||
)
|
||||
|
||||
user_info = await token_manager.get_user_info(keycloak_access_token)
|
||||
keycloak_user_id = user_info.sub
|
||||
user = await UserStore.get_user_by_id(keycloak_user_id)
|
||||
keycloak_user_id = user_info['sub']
|
||||
user = await UserStore.get_user_by_id_async(keycloak_user_id)
|
||||
if not user:
|
||||
return _html_response(
|
||||
title='Failed to authenticate.',
|
||||
@@ -209,7 +207,7 @@ async def keycloak_callback(
|
||||
# These tokens are offline access tokens - store them!
|
||||
await token_manager.store_offline_token(keycloak_user_id, keycloak_refresh_token)
|
||||
|
||||
idp: str = user_info.identity_provider or ProviderType.GITHUB.value
|
||||
idp: str = user_info.get('identity_provider', ProviderType.GITHUB)
|
||||
idp_type = 'oidc'
|
||||
if ':' in idp:
|
||||
idp, idp_type = idp.rsplit(':', 1)
|
||||
@@ -220,9 +218,9 @@ async def keycloak_callback(
|
||||
|
||||
# Retrieve bot token
|
||||
if team_id and bot_access_token:
|
||||
await slack_team_store.create_team(team_id, bot_access_token)
|
||||
slack_team_store.create_team(team_id, bot_access_token)
|
||||
else:
|
||||
bot_access_token = await slack_team_store.get_team_bot_token(team_id)
|
||||
bot_access_token = slack_team_store.get_team_bot_token(team_id)
|
||||
|
||||
if not bot_access_token:
|
||||
logger.error(
|
||||
@@ -241,15 +239,15 @@ async def keycloak_callback(
|
||||
slack_display_name=slack_display_name,
|
||||
)
|
||||
|
||||
async with a_session_maker(expire_on_commit=False) as session:
|
||||
with session_maker(expire_on_commit=False) as session:
|
||||
# First delete any existing tokens
|
||||
await session.execute(
|
||||
delete(SlackUser).where(SlackUser.slack_user_id == slack_user_id)
|
||||
)
|
||||
session.query(SlackUser).filter(
|
||||
SlackUser.slack_user_id == slack_user_id
|
||||
).delete()
|
||||
|
||||
# Store the token
|
||||
session.add(slack_user)
|
||||
await session.commit()
|
||||
session.commit()
|
||||
|
||||
message = Message(source=SourceType.SLACK, message=payload)
|
||||
|
||||
@@ -323,129 +321,9 @@ async def on_event(request: Request, background_tasks: BackgroundTasks):
|
||||
return JSONResponse({'success': True})
|
||||
|
||||
|
||||
@slack_router.post('/on-options-load')
|
||||
async def on_options_load(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Handle external_select options loading (block_suggestion payload).
|
||||
|
||||
This endpoint is called by Slack when a user interacts with an external_select
|
||||
element. It supports dynamic repository search with pagination.
|
||||
|
||||
The endpoint:
|
||||
1. Authenticates the Slack user
|
||||
2. Searches for repositories matching the user's query
|
||||
3. Returns up to 100 options for the dropdown
|
||||
|
||||
Configuration: Set the Options Load URL in Slack App settings to:
|
||||
https://your-domain/slack/on-options-load
|
||||
"""
|
||||
if not SLACK_WEBHOOKS_ENABLED:
|
||||
return JSONResponse({'options': []})
|
||||
|
||||
body = await request.body()
|
||||
form = await request.form()
|
||||
payload_str = form.get('payload')
|
||||
if not payload_str:
|
||||
logger.warning('slack_on_options_load: No payload in request')
|
||||
return JSONResponse({'options': []})
|
||||
|
||||
payload = json.loads(payload_str)
|
||||
|
||||
logger.info('slack_on_options_load', extra={'payload': payload})
|
||||
|
||||
# Verify the signature
|
||||
if not signature_verifier.is_valid(
|
||||
body=body,
|
||||
timestamp=request.headers.get('X-Slack-Request-Timestamp'),
|
||||
signature=request.headers.get('X-Slack-Signature'),
|
||||
):
|
||||
raise HTTPException(status_code=403, detail='invalid_request')
|
||||
|
||||
# Verify this is a block_suggestion payload
|
||||
if payload.get('type') != 'block_suggestion':
|
||||
logger.warning(
|
||||
f"slack_on_options_load: Unexpected payload type: {payload.get('type')}"
|
||||
)
|
||||
return JSONResponse({'options': []})
|
||||
|
||||
slack_user_id = payload['user']['id']
|
||||
search_value = payload.get('value', '') # What user typed in the search box
|
||||
|
||||
# Authenticate user
|
||||
slack_user, saas_user_auth = await slack_manager.authenticate_user(slack_user_id)
|
||||
|
||||
if not slack_user or not saas_user_auth:
|
||||
# Send ephemeral message asking user to link their account
|
||||
background_tasks.add_task(
|
||||
slack_manager.handle_slack_error,
|
||||
payload,
|
||||
SlackError(
|
||||
SlackErrorCode.USER_NOT_AUTHENTICATED,
|
||||
message_kwargs={'login_link': _generate_login_link()},
|
||||
log_context={'slack_user_id': slack_user_id},
|
||||
),
|
||||
)
|
||||
return JSONResponse({'options': []})
|
||||
|
||||
try:
|
||||
# Search for repositories matching the query
|
||||
# Limit to 20 repos for fast initial load. Users can search for repos
|
||||
# not in this list using the type-ahead search functionality.
|
||||
options = await slack_manager.search_repos_for_slack(
|
||||
saas_user_auth, query=search_value, per_page=20
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'slack_on_options_load_success',
|
||||
extra={
|
||||
'slack_user_id': slack_user_id,
|
||||
'search_value': search_value,
|
||||
'num_options': len(options),
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse({'options': options})
|
||||
|
||||
except ProviderTimeoutError as e:
|
||||
# Handle provider timeout with user notification
|
||||
background_tasks.add_task(
|
||||
slack_manager.handle_slack_error,
|
||||
payload,
|
||||
SlackError(
|
||||
SlackErrorCode.PROVIDER_TIMEOUT,
|
||||
log_context={'slack_user_id': slack_user_id, 'error': str(e)},
|
||||
),
|
||||
)
|
||||
return JSONResponse({'options': []})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'slack_options_load_error',
|
||||
extra={
|
||||
'slack_user_id': slack_user_id,
|
||||
'search_value': search_value,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
# Notify user about the unexpected error with error code
|
||||
background_tasks.add_task(
|
||||
slack_manager.handle_slack_error,
|
||||
payload,
|
||||
SlackError(
|
||||
SlackErrorCode.UNEXPECTED_ERROR,
|
||||
log_context={'slack_user_id': slack_user_id, 'error': str(e)},
|
||||
),
|
||||
)
|
||||
return JSONResponse({'options': []})
|
||||
|
||||
|
||||
@slack_router.post('/on-form-interaction')
|
||||
async def on_form_interaction(request: Request, background_tasks: BackgroundTasks):
|
||||
"""Handle repository selection form submission.
|
||||
|
||||
When a user selects a repository from the external_select dropdown,
|
||||
this endpoint passes the payload to the manager which retrieves the
|
||||
original user message from Redis and starts the conversation.
|
||||
"""
|
||||
"""We check the nonce to start a conversation"""
|
||||
if not SLACK_WEBHOOKS_ENABLED:
|
||||
return JSONResponse({'success': 'slack_webhooks_disabled'})
|
||||
|
||||
@@ -455,7 +333,7 @@ async def on_form_interaction(request: Request, background_tasks: BackgroundTask
|
||||
|
||||
logger.info('slack_on_form_interaction', extra={'payload': payload})
|
||||
|
||||
# Verify the signature
|
||||
# First verify the signature
|
||||
if not signature_verifier.is_valid(
|
||||
body=body,
|
||||
timestamp=request.headers.get('X-Slack-Request-Timestamp'),
|
||||
@@ -464,16 +342,40 @@ async def on_form_interaction(request: Request, background_tasks: BackgroundTask
|
||||
raise HTTPException(status_code=403, detail='invalid_request')
|
||||
|
||||
assert payload['type'] == 'block_actions'
|
||||
selected_repository = payload['actions'][0]['selected_option'][
|
||||
'value'
|
||||
] # Get the repository
|
||||
if selected_repository == '-':
|
||||
selected_repository = None
|
||||
slack_user_id = payload['user']['id']
|
||||
channel_id = payload['container']['channel_id']
|
||||
team_id = payload['team']['id']
|
||||
# Hack - get original message_ts from element name
|
||||
attribs = 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
|
||||
# Get the original message
|
||||
# Get the text message
|
||||
# Start the conversation
|
||||
|
||||
background_tasks.add_task(slack_manager.receive_form_interaction, payload)
|
||||
payload = {
|
||||
'message_ts': message_ts,
|
||||
'thread_ts': thread_ts,
|
||||
'channel_id': channel_id,
|
||||
'slack_user_id': slack_user_id,
|
||||
'selected_repo': selected_repository,
|
||||
'team_id': team_id,
|
||||
}
|
||||
|
||||
message = Message(
|
||||
source=SourceType.SLACK,
|
||||
message=payload,
|
||||
)
|
||||
|
||||
background_tasks.add_task(slack_manager.receive_message, message)
|
||||
return JSONResponse({'success': True})
|
||||
|
||||
|
||||
def _generate_login_link(state: str = '') -> str:
|
||||
"""Generate the OAuth login link for Slack authentication."""
|
||||
return authorize_url_generator.generate(state)
|
||||
|
||||
|
||||
def _html_response(title: str, description: str, status_code: int) -> HTMLResponse:
|
||||
content = (
|
||||
'<style>body{background:#0d0f11;color:#ecedee;font-family:sans-serif;display:flex;justify-content:center;align-items:center;}</style>'
|
||||
|
||||
@@ -6,8 +6,8 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from server.utils.url_utils import get_web_url
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.database import session_maker
|
||||
from storage.device_code_store import DeviceCodeStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -54,7 +54,7 @@ class DeviceTokenErrorResponse(BaseModel):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
oauth_device_router = APIRouter(prefix='/oauth/device')
|
||||
device_code_store = DeviceCodeStore()
|
||||
device_code_store = DeviceCodeStore(session_maker)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -90,11 +90,11 @@ async def device_authorization(
|
||||
) -> DeviceAuthorizationResponse:
|
||||
"""Start device flow by generating device and user codes."""
|
||||
try:
|
||||
device_code_entry = await device_code_store.create_device_code(
|
||||
device_code_entry = device_code_store.create_device_code(
|
||||
expires_in=DEVICE_CODE_EXPIRES_IN,
|
||||
)
|
||||
|
||||
base_url = get_web_url(http_request)
|
||||
base_url = str(http_request.base_url).rstrip('/')
|
||||
verification_uri = f'{base_url}/oauth/device/verify'
|
||||
verification_uri_complete = (
|
||||
f'{verification_uri}?user_code={device_code_entry.user_code}'
|
||||
@@ -125,7 +125,7 @@ async def device_authorization(
|
||||
async def device_token(device_code: str = Form(...)):
|
||||
"""Poll for a token until the user authorizes or the code expires."""
|
||||
try:
|
||||
device_code_entry = await device_code_store.get_by_device_code(device_code)
|
||||
device_code_entry = device_code_store.get_by_device_code(device_code)
|
||||
|
||||
if not device_code_entry:
|
||||
return _oauth_error(
|
||||
@@ -138,9 +138,7 @@ async def device_token(device_code: str = Form(...)):
|
||||
is_too_fast, current_interval = device_code_entry.check_rate_limit()
|
||||
if is_too_fast:
|
||||
# Update poll time and increase interval
|
||||
await device_code_store.update_poll_time(
|
||||
device_code, increase_interval=True
|
||||
)
|
||||
device_code_store.update_poll_time(device_code, increase_interval=True)
|
||||
logger.warning(
|
||||
'Client polling too fast, returning slow_down error',
|
||||
extra={
|
||||
@@ -156,7 +154,7 @@ async def device_token(device_code: str = Form(...)):
|
||||
)
|
||||
|
||||
# Update poll time for successful rate limit check
|
||||
await device_code_store.update_poll_time(device_code, increase_interval=False)
|
||||
device_code_store.update_poll_time(device_code, increase_interval=False)
|
||||
|
||||
if device_code_entry.is_expired():
|
||||
return _oauth_error(
|
||||
@@ -183,7 +181,7 @@ async def device_token(device_code: str = Form(...)):
|
||||
# Retrieve the specific API key for this device using the user_code
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
device_key_name = f'{API_KEY_NAME} ({device_code_entry.user_code})'
|
||||
device_api_key = await api_key_store.retrieve_api_key_by_name(
|
||||
device_api_key = api_key_store.retrieve_api_key_by_name(
|
||||
device_code_entry.keycloak_user_id, device_key_name
|
||||
)
|
||||
|
||||
@@ -240,7 +238,7 @@ async def device_verification_authenticated(
|
||||
)
|
||||
|
||||
# Validate device code
|
||||
device_code_entry = await device_code_store.get_by_user_code(user_code)
|
||||
device_code_entry = device_code_store.get_by_user_code(user_code)
|
||||
if not device_code_entry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -254,7 +252,7 @@ async def device_verification_authenticated(
|
||||
)
|
||||
|
||||
# First, authorize the device code
|
||||
success = await device_code_store.authorize_device_code(
|
||||
success = device_code_store.authorize_device_code(
|
||||
user_code=user_code,
|
||||
user_id=user_id,
|
||||
)
|
||||
@@ -291,7 +289,7 @@ async def device_verification_authenticated(
|
||||
# Clean up: revert the device authorization since API key creation failed
|
||||
# This prevents the device from being in an authorized state without an API key
|
||||
try:
|
||||
await device_code_store.deny_device_code(user_code)
|
||||
device_code_store.deny_device_code(user_code)
|
||||
logger.info(
|
||||
'Reverted device authorization due to API key creation failure',
|
||||
extra={'user_code': user_code, 'user_id': user_id},
|
||||
|
||||
@@ -76,7 +76,7 @@ class InvitationResponse(BaseModel):
|
||||
inviter_email: str | None = None
|
||||
|
||||
@classmethod
|
||||
async def from_invitation(
|
||||
def from_invitation(
|
||||
cls,
|
||||
invitation: OrgInvitation,
|
||||
inviter_email: str | None = None,
|
||||
@@ -94,7 +94,7 @@ class InvitationResponse(BaseModel):
|
||||
if invitation.role:
|
||||
role_name = invitation.role.name
|
||||
elif invitation.role_id:
|
||||
role = await RoleStore.get_role_by_id(invitation.role_id)
|
||||
role = RoleStore.get_role_by_id(invitation.role_id)
|
||||
role_name = role.name if role else ''
|
||||
|
||||
return cls(
|
||||
|
||||
@@ -91,11 +91,8 @@ async def create_invitation(
|
||||
},
|
||||
)
|
||||
|
||||
successful_responses = [
|
||||
await InvitationResponse.from_invitation(inv) for inv in successful
|
||||
]
|
||||
return BatchInvitationResponse(
|
||||
successful=successful_responses,
|
||||
successful=[InvitationResponse.from_invitation(inv) for inv in successful],
|
||||
failed=[
|
||||
InvitationFailure(email=email, error=error) for email, error in failed
|
||||
],
|
||||
|
||||
@@ -99,13 +99,13 @@ async def list_user_orgs(
|
||||
|
||||
try:
|
||||
# Fetch user to get current_org_id
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
current_org_id = (
|
||||
str(user.current_org_id) if user and user.current_org_id else None
|
||||
)
|
||||
|
||||
# Fetch organizations from service layer
|
||||
orgs, next_page_id = await OrgService.get_user_orgs_paginated(
|
||||
orgs, next_page_id = OrgService.get_user_orgs_paginated(
|
||||
user_id=user_id,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
@@ -497,7 +497,7 @@ async def get_me(
|
||||
|
||||
try:
|
||||
user_uuid = UUID(user_id)
|
||||
return await OrgMemberService.get_me(org_id, user_uuid)
|
||||
return OrgMemberService.get_me(org_id, user_uuid)
|
||||
|
||||
except OrgMemberNotFoundError:
|
||||
raise HTTPException(
|
||||
@@ -781,7 +781,7 @@ async def get_org_members(
|
||||
)
|
||||
|
||||
if not success:
|
||||
error_map: dict[str | None, tuple[int, str]] = {
|
||||
error_map = {
|
||||
'not_a_member': (
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
'You are not a member of this organization',
|
||||
@@ -790,14 +790,9 @@ async def get_org_members(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'Invalid page_id format',
|
||||
),
|
||||
None: (
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
'An error occurred',
|
||||
),
|
||||
}
|
||||
status_code, detail = error_map.get(
|
||||
error_code,
|
||||
(status.HTTP_500_INTERNAL_SERVER_ERROR, 'An error occurred'),
|
||||
error_code, (status.HTTP_500_INTERNAL_SERVER_ERROR, 'An error occurred')
|
||||
)
|
||||
raise HTTPException(status_code=status_code, detail=detail)
|
||||
|
||||
@@ -905,7 +900,7 @@ async def remove_org_member(
|
||||
)
|
||||
|
||||
if not success:
|
||||
error_map: dict[str | None, tuple[int, str]] = {
|
||||
error_map = {
|
||||
'not_a_member': (
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
'You are not a member of this organization',
|
||||
@@ -930,14 +925,9 @@ async def remove_org_member(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
'Failed to remove member',
|
||||
),
|
||||
None: (
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
'An error occurred',
|
||||
),
|
||||
}
|
||||
status_code, detail = error_map.get(
|
||||
error,
|
||||
(status.HTTP_500_INTERNAL_SERVER_ERROR, 'An error occurred'),
|
||||
error, (status.HTTP_500_INTERNAL_SERVER_ERROR, 'An error occurred')
|
||||
)
|
||||
raise HTTPException(status_code=status_code, detail=detail)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy.sql import text
|
||||
from storage.database import a_session_maker
|
||||
from storage.database import session_maker
|
||||
from storage.redis import create_redis_client
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -9,11 +9,11 @@ readiness_router = APIRouter()
|
||||
|
||||
|
||||
@readiness_router.get('/ready')
|
||||
async def is_ready():
|
||||
def is_ready():
|
||||
# Check database connection
|
||||
try:
|
||||
async with a_session_maker() as session:
|
||||
await session.execute(text('SELECT 1'))
|
||||
with session_maker() as session:
|
||||
session.execute(text('SELECT 1'))
|
||||
except Exception as e:
|
||||
logger.error(f'Database check failed: {str(e)}')
|
||||
raise HTTPException(
|
||||
|
||||
@@ -111,26 +111,30 @@ async def saas_get_user(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
user_info = await token_manager.get_user_info(access_token.get_secret_value())
|
||||
if not user_info:
|
||||
return JSONResponse(
|
||||
content='Failed to retrieve user_info.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
# Prefer email from DB; fall back to Keycloak if not yet persisted
|
||||
email = user_info.email
|
||||
sub = user_info.sub
|
||||
email = user_info.get('email') if user_info else None
|
||||
sub = user_info.get('sub') if user_info else ''
|
||||
if sub:
|
||||
db_user = await UserStore.get_user_by_id(sub)
|
||||
db_user = await UserStore.get_user_by_id_async(sub)
|
||||
if db_user and db_user.email is not None:
|
||||
email = db_user.email
|
||||
|
||||
user_info_dict = user_info.model_dump(exclude_none=True)
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value=User(
|
||||
id=sub,
|
||||
login=user_info.preferred_username or '',
|
||||
login=(user_info.get('preferred_username') if user_info else '') or '',
|
||||
avatar_url='',
|
||||
email=email,
|
||||
name=resolve_display_name(user_info_dict),
|
||||
company=user_info.company,
|
||||
name=resolve_display_name(user_info) if user_info else None,
|
||||
company=user_info.get('company') if user_info else None,
|
||||
),
|
||||
user_info=user_info_dict,
|
||||
user_info=user_info,
|
||||
)
|
||||
if retval is not None:
|
||||
return retval
|
||||
@@ -360,11 +364,16 @@ async def _check_idp(
|
||||
content='User is not authenticated.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
if user_info is None:
|
||||
user_info_model = await token_manager.get_user_info(
|
||||
access_token.get_secret_value()
|
||||
user_info = (
|
||||
user_info
|
||||
if user_info
|
||||
else await token_manager.get_user_info(access_token.get_secret_value())
|
||||
)
|
||||
if not user_info:
|
||||
return JSONResponse(
|
||||
content='Failed to retrieve user_info.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
user_info = user_info_model.model_dump(exclude_none=True)
|
||||
idp: str | None = user_info.get('identity_provider')
|
||||
if not idp:
|
||||
return JSONResponse(
|
||||
@@ -379,4 +388,5 @@ async def _check_idp(
|
||||
access_token.get_secret_value(), ProviderType(idp)
|
||||
):
|
||||
return default_value
|
||||
|
||||
return None
|
||||
|
||||
@@ -19,9 +19,9 @@ from server.utils.conversation_callback_utils import (
|
||||
process_event,
|
||||
update_conversation_metadata,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import orm
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.database import a_session_maker
|
||||
from storage.database import session_maker
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
@@ -59,6 +59,7 @@ from openhands.storage.locations import (
|
||||
get_conversation_event_filename,
|
||||
get_conversation_events_dir,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
from openhands.utils.import_utils import get_impl
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
@@ -165,8 +166,8 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
}
|
||||
|
||||
if user_id:
|
||||
user_conversation_ids = await self._get_recent_conversation_ids_for_user(
|
||||
user_id
|
||||
user_conversation_ids = await call_sync_from_async(
|
||||
self._get_recent_conversation_ids_for_user, user_id
|
||||
)
|
||||
conversation_ids = conversation_ids.intersection(user_conversation_ids)
|
||||
|
||||
@@ -391,11 +392,39 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
await self._setup_nested_settings(client, api_url, settings)
|
||||
await self._setup_provider_tokens(client, api_url, settings)
|
||||
await self._setup_custom_secrets(client, api_url, settings.custom_secrets) # type: ignore
|
||||
await self._setup_experiment_config(client, api_url, sid, user_id)
|
||||
await self._create_nested_conversation(
|
||||
client, api_url, sid, user_id, settings, initial_user_msg, replay_json
|
||||
)
|
||||
await self._wait_for_conversation_ready(client, api_url, sid)
|
||||
|
||||
async def _setup_experiment_config(
|
||||
self, client: httpx.AsyncClient, api_url: str, sid: str, user_id: str
|
||||
):
|
||||
# Prevent circular import
|
||||
from openhands.experiments.experiment_manager import (
|
||||
ExperimentConfig,
|
||||
ExperimentManagerImpl,
|
||||
)
|
||||
|
||||
config: OpenHandsConfig = ExperimentManagerImpl.run_config_variant_test(
|
||||
user_id, sid, self.config
|
||||
)
|
||||
|
||||
experiment_config = ExperimentConfig(
|
||||
config={
|
||||
'system_prompt_filename': config.get_agent_config(
|
||||
config.default_agent
|
||||
).system_prompt_filename
|
||||
}
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
f'{api_url}/api/conversations/{sid}/exp-config',
|
||||
json=experiment_config.model_dump(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
async def _setup_nested_settings(
|
||||
self, client: httpx.AsyncClient, api_url: str, settings: Settings
|
||||
) -> None:
|
||||
@@ -614,18 +643,19 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
},
|
||||
)
|
||||
|
||||
async def _get_user_id_from_conversation(self, conversation_id: str) -> str:
|
||||
def _get_user_id_from_conversation(self, conversation_id: str) -> str:
|
||||
"""
|
||||
Get user_id from conversation_id.
|
||||
"""
|
||||
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(StoredConversationMetadataSaas).where(
|
||||
with session_maker() as session:
|
||||
conversation_metadata_saas = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(
|
||||
StoredConversationMetadataSaas.conversation_id == conversation_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
conversation_metadata_saas = result.scalars().first()
|
||||
|
||||
if not conversation_metadata_saas:
|
||||
raise ValueError(f'No conversation found {conversation_id}')
|
||||
@@ -723,8 +753,8 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
user_id_for_convo = user_id
|
||||
if not user_id_for_convo:
|
||||
try:
|
||||
user_id_for_convo = await self._get_user_id_from_conversation(
|
||||
conversation_id
|
||||
user_id_for_convo = await call_sync_from_async(
|
||||
self._get_user_id_from_conversation, conversation_id
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
@@ -965,23 +995,23 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
}
|
||||
return conversation_ids
|
||||
|
||||
async def _get_recent_conversation_ids_for_user(self, user_id: str) -> set[str]:
|
||||
async with a_session_maker() as session:
|
||||
def _get_recent_conversation_ids_for_user(self, user_id: str) -> set[str]:
|
||||
with session_maker() as session:
|
||||
# Only include conversations updated in the past week
|
||||
one_week_ago = datetime.now(UTC) - timedelta(days=7)
|
||||
result = await session.execute(
|
||||
select(StoredConversationMetadata.conversation_id)
|
||||
query = (
|
||||
session.query(StoredConversationMetadata.conversation_id)
|
||||
.join(
|
||||
StoredConversationMetadataSaas,
|
||||
StoredConversationMetadata.conversation_id
|
||||
== StoredConversationMetadataSaas.conversation_id,
|
||||
)
|
||||
.where(
|
||||
.filter(
|
||||
StoredConversationMetadataSaas.user_id == user_id,
|
||||
StoredConversationMetadata.last_updated_at >= one_week_ago,
|
||||
)
|
||||
)
|
||||
user_conversation_ids = set(result.scalars().all())
|
||||
user_conversation_ids = set(query)
|
||||
return user_conversation_ids
|
||||
|
||||
async def _get_runtime(self, sid: str) -> dict | None:
|
||||
@@ -1025,13 +1055,14 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
await asyncio.sleep(_POLLING_INTERVAL)
|
||||
agent_loop_infos = await self.get_agent_loop_info()
|
||||
|
||||
for agent_loop_info in agent_loop_infos:
|
||||
if agent_loop_info.status != ConversationStatus.RUNNING:
|
||||
continue
|
||||
try:
|
||||
await self._poll_agent_loop_events(agent_loop_info)
|
||||
except Exception as e:
|
||||
logger.exception(f'error_polling_events:{str(e)}')
|
||||
with session_maker() as session:
|
||||
for agent_loop_info in agent_loop_infos:
|
||||
if agent_loop_info.status != ConversationStatus.RUNNING:
|
||||
continue
|
||||
try:
|
||||
await self._poll_agent_loop_events(agent_loop_info, session)
|
||||
except Exception as e:
|
||||
logger.exception(f'error_polling_events:{str(e)}')
|
||||
except Exception as e:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
@@ -1040,27 +1071,23 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
# Loop has been shut down, exit gracefully
|
||||
return
|
||||
|
||||
async def _poll_agent_loop_events(self, agent_loop_info: AgentLoopInfo):
|
||||
async def _poll_agent_loop_events(
|
||||
self, agent_loop_info: AgentLoopInfo, session: orm.Session
|
||||
):
|
||||
"""This method is typically only run in localhost, where the webhook callbacks from the remote runtime are unavailable"""
|
||||
if agent_loop_info.status != ConversationStatus.RUNNING:
|
||||
return
|
||||
conversation_id = agent_loop_info.conversation_id
|
||||
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(StoredConversationMetadata).where(
|
||||
StoredConversationMetadata.conversation_id == conversation_id
|
||||
)
|
||||
)
|
||||
conversation_metadata = result.scalars().first()
|
||||
|
||||
result = await session.execute(
|
||||
select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == conversation_id
|
||||
)
|
||||
)
|
||||
conversation_metadata_saas = result.scalars().first()
|
||||
|
||||
conversation_metadata = (
|
||||
session.query(StoredConversationMetadata)
|
||||
.filter(StoredConversationMetadata.conversation_id == conversation_id)
|
||||
.first()
|
||||
)
|
||||
conversation_metadata_saas = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(StoredConversationMetadataSaas.conversation_id == conversation_id)
|
||||
.first()
|
||||
)
|
||||
if conversation_metadata is None or conversation_metadata_saas is None:
|
||||
# Conversation is running in different server
|
||||
return
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user