mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf877b5628 | ||
|
|
fb9958aff8 | ||
|
|
c1f5861eaf | ||
|
|
fa7f58b7c5 | ||
|
|
a691bec7fc | ||
|
|
7eb77c131d | ||
|
|
858870a095 | ||
|
|
d65e5b5e46 | ||
|
|
2b0816f53a | ||
|
|
ab9536dc6b | ||
|
|
9f5888315a | ||
|
|
fcefb872b6 | ||
|
|
b91cd0570e | ||
|
|
e18775b391 | ||
|
|
495de35139 | ||
|
|
be29d89b3c | ||
|
|
2890b8c6ff | ||
|
|
39a846ccc3 | ||
|
|
cae7b6e72f | ||
|
|
7ca41486be | ||
|
|
81c02623a1 | ||
|
|
38dcf959bc | ||
|
|
ef3acf726c | ||
|
|
017d758a76 | ||
|
|
3ed37e18ac | ||
|
|
1322f944be | ||
|
|
5925483f6b | ||
|
|
0144424c8e | ||
|
|
f07ce85b45 | ||
|
|
bc5a46dcee | ||
|
|
9990870060 | ||
|
|
bab9a45590 | ||
|
|
b4107ff9dc | ||
|
|
3e04713097 | ||
|
|
77f868081c | ||
|
|
3a12924bc8 | ||
|
|
cfa7def554 | ||
|
|
33d6a11abf | ||
|
|
71d5aa5aa8 | ||
|
|
90d2681e34 | ||
|
|
565a5702c3 | ||
|
|
4b9097068d | ||
|
|
c9a5834164 | ||
|
|
19a089aa4b | ||
|
|
918c44d164 | ||
|
|
e06e20a5ba | ||
|
|
430ee1c9fd | ||
|
|
a03377698c | ||
|
|
9dab5b1bbf | ||
|
|
135d5fbd38 | ||
|
|
ad615ebc8b | ||
|
|
424f6b30d1 |
@@ -1,47 +0,0 @@
|
||||
---
|
||||
name: custom-codereview-guide
|
||||
description: Repo-specific code review guidelines for All-Hands-AI/OpenHands. Provides frontend and backend review rules in addition to the default code review skill.
|
||||
triggers:
|
||||
- /codereview
|
||||
---
|
||||
|
||||
# All-Hands-AI/OpenHands Code Review Guidelines
|
||||
|
||||
You are an expert code reviewer for the **All-Hands-AI/OpenHands** repository. This skill provides repo-specific review guidelines.
|
||||
|
||||
## Frontend: i18n / Translation Key Usage
|
||||
|
||||
**Never dynamically construct i18n keys via string interpolation or template literals.**
|
||||
|
||||
All translation keys must come from the `I18nKey` enum (`frontend/src/i18n/declaration.ts`) or from canonical mapping objects like `AGENT_STATUS_MAP` (`frontend/src/utils/status.ts`). Dynamically constructed keys (e.g., `` t(`STATUS$${value.toUpperCase()}`) ``) will silently fall back to the raw key string at runtime because `i18next` returns the key itself when a translation is missing — this produces broken UI text with no build-time or test-time error.
|
||||
|
||||
### What to flag
|
||||
|
||||
- Any call to `t(...)` or `i18next.t(...)` where the key is built at runtime via template literals, string concatenation, or helper functions rather than referencing `I18nKey` or a known mapping
|
||||
- Any new i18n key referenced in code that does not exist in `frontend/src/i18n/translation.json`
|
||||
|
||||
### Correct pattern
|
||||
|
||||
```ts
|
||||
import { AGENT_STATUS_MAP } from "#/utils/status";
|
||||
|
||||
const i18nKey = AGENT_STATUS_MAP[agentState];
|
||||
const message = i18nKey ? t(i18nKey) : fallback;
|
||||
```
|
||||
|
||||
### Incorrect pattern
|
||||
|
||||
```ts
|
||||
// BAD: constructs a key that may not exist in translation.json
|
||||
const message = t(`STATUS$${agentState.toUpperCase()}`);
|
||||
```
|
||||
|
||||
## Frontend: Data Fetching Architecture
|
||||
|
||||
UI components must never call API client methods (`frontend/src/api/`) directly. All data access must go through TanStack Query hooks:
|
||||
|
||||
```
|
||||
UI components → TanStack Query hooks (frontend/src/hooks/query/ or mutation/) → API client (frontend/src/api/) → API endpoints
|
||||
```
|
||||
|
||||
Flag any component that imports directly from `#/api/` and calls fetch/mutation functions without a TanStack Query wrapper.
|
||||
@@ -95,13 +95,13 @@ git tag X.Y.Z
|
||||
|
||||
Create a `saas-rel-X.Y.Z` branch from the tagged commit for the SaaS deployment pipeline.
|
||||
|
||||
#### Step 3: Images get tagged automatically
|
||||
#### Step 3: CI builds Docker images automatically
|
||||
|
||||
Every push to `main` / `saas-rel-*` / `oss-rel-*` builds and publishes `ghcr.io/openhands/openhands` and `ghcr.io/openhands/enterprise-server` images for that commit (tagged by SHA, short SHA, and branch name).
|
||||
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`
|
||||
|
||||
Pushing a git tag `X.Y.Z` then tags the images for that commit with `X.Y.Z`, `X.Y`, `X`, and `latest`. Non-semver tags just get their literal name applied.
|
||||
|
||||
Requires the commit to already be built. If you push the tag too early, the retag CI job fails loudly — re-run it from the Actions UI once the build completes.
|
||||
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
|
||||
|
||||
|
||||
@@ -46,16 +46,39 @@ These files contain image tags that **must** be updated whenever the SDK version
|
||||
### `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
.gitattributes
vendored
1
.gitattributes
vendored
@@ -4,5 +4,4 @@
|
||||
* text eol=lf
|
||||
# Git incorrectly thinks some media is text
|
||||
*.png -text
|
||||
*.gif -text
|
||||
*.mp4 -text
|
||||
|
||||
8
.github/CODEOWNERS
vendored
Normal file
8
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# CODEOWNERS file for OpenHands repository
|
||||
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
|
||||
/frontend/ @amanape @hieptl
|
||||
/openhands-ui/ @amanape @hieptl
|
||||
/openhands/ @tofarr @malhotra5 @hieptl
|
||||
/enterprise/ @chuckbutkus @tofarr @malhotra5
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
51
.github/actions/docker-image-tags/action.yml
vendored
51
.github/actions/docker-image-tags/action.yml
vendored
@@ -1,51 +0,0 @@
|
||||
name: Compute Docker image tags
|
||||
description: Produce the canonical OpenHands Docker tag set (ref name, short SHA, full SHA — each in bare and `sha-` prefixed form) for a given image, with optional suffix and extra raw tags.
|
||||
|
||||
inputs:
|
||||
image:
|
||||
description: Fully qualified image name (e.g. ghcr.io/owner/openhands).
|
||||
required: true
|
||||
ref-name:
|
||||
description: Git ref name to emit as a tag (e.g. main, pr-123, saas-rel-1.2.3).
|
||||
required: true
|
||||
suffix:
|
||||
description: Suffix appended to every tag (e.g. -amd64, -nikolaik-arm64). Leave empty for base (multi-arch manifest) tags.
|
||||
required: false
|
||||
default: ""
|
||||
extra-tags:
|
||||
description: Additional newline-separated metadata-action tag rules (e.g. extra `type=raw,value=...` lines).
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
outputs:
|
||||
tags:
|
||||
description: Newline-separated list of fully qualified image tags.
|
||||
value: ${{ steps.meta.outputs.tags }}
|
||||
labels:
|
||||
description: Image labels emitted by docker/metadata-action.
|
||||
value: ${{ steps.meta.outputs.labels }}
|
||||
version:
|
||||
description: Sanitized version string (ref-name with any suffix applied). Safe to use in docker tags.
|
||||
value: ${{ steps.meta.outputs.version }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Compute tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
env:
|
||||
# Use the PR head SHA (not the merge SHA) for sha-prefixed tags.
|
||||
DOCKER_METADATA_PR_HEAD_SHA: "true"
|
||||
with:
|
||||
images: ${{ inputs.image }}
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=${{ inputs.suffix }}
|
||||
tags: |
|
||||
type=raw,value=${{ inputs.ref-name }}
|
||||
type=sha,prefix=sha-
|
||||
type=sha,prefix=
|
||||
type=sha,format=long,prefix=sha-
|
||||
type=sha,format=long,prefix=
|
||||
${{ inputs.extra-tags }}
|
||||
43
.github/actions/docker-merge-manifest/action.yml
vendored
43
.github/actions/docker-merge-manifest/action.yml
vendored
@@ -1,43 +0,0 @@
|
||||
name: Merge multi-arch Docker manifest
|
||||
description: Build a multi-arch manifest from per-arch image tags pushed by an earlier build step.
|
||||
|
||||
inputs:
|
||||
base-tags:
|
||||
description: Newline-separated list of base tags (without architecture suffix).
|
||||
required: true
|
||||
archs:
|
||||
description: Space-separated list of architectures (e.g. "amd64 arm64").
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create multi-arch manifests
|
||||
shell: bash
|
||||
env:
|
||||
BASE_TAGS: ${{ inputs.base-tags }}
|
||||
ARCHS: ${{ inputs.archs }}
|
||||
run: |
|
||||
while IFS= read -r tag; do
|
||||
[[ -z "$tag" ]] && continue
|
||||
sources=""
|
||||
for arch in $ARCHS; do
|
||||
if ! docker buildx imagetools inspect "${tag}-${arch}" > /dev/null 2>&1; then
|
||||
echo "::error::Missing image ${tag}-${arch}"
|
||||
exit 1
|
||||
fi
|
||||
sources+=" ${tag}-${arch}"
|
||||
done
|
||||
echo "Creating manifest for $tag from:$sources"
|
||||
docker buildx imagetools create -t "$tag" $sources
|
||||
done <<< "$BASE_TAGS"
|
||||
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
@@ -4,7 +4,7 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 5
|
||||
open-pull-requests-limit: 1
|
||||
groups:
|
||||
# put packages in their own group if they have a history of breaking the build or needing to be reverted
|
||||
pre-commit:
|
||||
@@ -29,7 +29,7 @@ updates:
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 5
|
||||
open-pull-requests-limit: 1
|
||||
groups:
|
||||
docusaurus:
|
||||
patterns:
|
||||
@@ -51,7 +51,7 @@ updates:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "wednesday"
|
||||
open-pull-requests-limit: 5
|
||||
open-pull-requests-limit: 1
|
||||
groups:
|
||||
docusaurus:
|
||||
patterns:
|
||||
@@ -72,11 +72,9 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directories:
|
||||
- "containers/*"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
62
.github/pull_request_template.md
vendored
62
.github/pull_request_template.md
vendored
@@ -1,46 +1,38 @@
|
||||
<!-- Keep this PR as draft until it is ready for review. -->
|
||||
<!-- If you are still working on the PR, please mark it as draft. Maintainers will review PRs marked ready for review, which leads to lost time if your PR is actually not ready yet. Keep the PR marked as draft until it is finally ready for review -->
|
||||
|
||||
<!-- AI/LLM agents: be concise and specific. Do not check the box below. -->
|
||||
## Summary of PR
|
||||
|
||||
- [ ] A human has tested these changes.
|
||||
<!-- Summarize what the PR does -->
|
||||
|
||||
---
|
||||
## Demo Screenshots/Videos
|
||||
|
||||
## Why
|
||||
<!-- AI/LLM AGENTS: This section is intended for a human author to add screenshots or videos demonstrating the PR in action (optional). While many pull requests may be generated by AI/LLM agents, we are fine with this as long as a human author has reviewed and tested the changes to ensure accuracy and functionality. -->
|
||||
|
||||
<!-- Describe problem, motivation, etc.-->
|
||||
## Change Type
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- 1-3 bullets describing what changed. -->
|
||||
-
|
||||
|
||||
## Issue Number
|
||||
<!-- Required if there is a relevant issue to this PR. -->
|
||||
|
||||
## How to Test
|
||||
|
||||
<!--
|
||||
Required. Share the steps for the reviewer to be able to test your PR. e.g. You can test by running `npm install` then `npm build dev`.
|
||||
|
||||
If you could not test this, say why.
|
||||
-->
|
||||
|
||||
## Video/Screenshots
|
||||
|
||||
<!--
|
||||
Provide a video or screenshots of testing your PR. e.g. you added a new feature to the gui, show us the video of you testing it successfully.
|
||||
|
||||
-->
|
||||
|
||||
## Type
|
||||
<!-- Choose the types that apply to your PR -->
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Refactor
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Docs / chore
|
||||
- [ ] Refactor
|
||||
- [ ] Other (dependency update, docs, typo fixes, etc.)
|
||||
|
||||
## Notes
|
||||
## Checklist
|
||||
<!-- AI/LLM AGENTS: This checklist is for a human author to complete. Do NOT check either of the two boxes below. Leave them unchecked until a human has personally reviewed and tested the changes. -->
|
||||
|
||||
<!-- Optional: migrations, config changes, rollout concerns, follow-ups, or anything reviewers should know. -->
|
||||
- [ ] I have read and reviewed the code and I understand what the code is doing.
|
||||
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
|
||||
|
||||
## Fixes
|
||||
|
||||
<!-- If this resolves an issue, link it here so it will close automatically upon merge. -->
|
||||
|
||||
Resolves #(issue)
|
||||
|
||||
## Release Notes
|
||||
|
||||
<!-- Check the box if this change is worth adding to the release notes. If checked, you must provide an
|
||||
end-user friendly description for your change below the checkbox. -->
|
||||
|
||||
- [ ] Include this change in the Release Notes.
|
||||
|
||||
1
.github/scripts/update_pr_description.sh
vendored
1
.github/scripts/update_pr_description.sh
vendored
@@ -13,6 +13,7 @@ DOCKER_RUN_COMMAND="docker run -it --rm \
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \
|
||||
--name openhands-app-${SHORT_SHA} \
|
||||
docker.openhands.dev/openhands/openhands:${SHORT_SHA}"
|
||||
|
||||
|
||||
116
.github/workflows/_build-image.yml
vendored
116
.github/workflows/_build-image.yml
vendored
@@ -1,116 +0,0 @@
|
||||
# Reusable workflow: build a multi-arch Docker image and publish a merged manifest.
|
||||
# Called per image from .github/workflows/ghcr-build.yml.
|
||||
name: Build and push multi-arch image
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
image:
|
||||
description: Fully-qualified image name (e.g. "ghcr.io/all-hands-ai/openhands").
|
||||
required: true
|
||||
type: string
|
||||
context:
|
||||
description: Docker build context.
|
||||
required: false
|
||||
type: string
|
||||
default: "."
|
||||
dockerfile:
|
||||
description: Path to the Dockerfile.
|
||||
required: true
|
||||
type: string
|
||||
extra-build-args:
|
||||
description: Additional build-args (newline-separated). OPENHANDS_BUILD_VERSION is added automatically.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
provenance:
|
||||
description: Value passed to docker/build-push-action provenance.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
sbom:
|
||||
description: Value passed to docker/build-push-action sbom.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
buildx-driver-opts:
|
||||
description: Extra buildx driver-opts (e.g. "network=host" for enterprise).
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
env:
|
||||
RELEVANT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
RELEVANT_REF_NAME: ${{ github.event.pull_request.number && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ inputs.image }} (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: ${{ inputs.buildx-driver-opts }}
|
||||
- name: Compute per-arch tags
|
||||
id: meta
|
||||
uses: ./.github/actions/docker-image-tags
|
||||
with:
|
||||
image: ${{ inputs.image }}
|
||||
ref-name: ${{ env.RELEVANT_REF_NAME }}
|
||||
suffix: -${{ matrix.arch }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
file: ${{ inputs.dockerfile }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
build-args: |
|
||||
OPENHANDS_BUILD_VERSION=${{ env.RELEVANT_REF_NAME }}
|
||||
${{ inputs.extra-build-args }}
|
||||
cache-from: |
|
||||
type=registry,ref=${{ inputs.image }}:buildcache-${{ steps.meta.outputs.version }}
|
||||
type=registry,ref=${{ inputs.image }}:buildcache-main-${{ matrix.arch }}
|
||||
cache-to: type=registry,ref=${{ inputs.image }}:buildcache-${{ steps.meta.outputs.version }},mode=max
|
||||
provenance: ${{ inputs.provenance }}
|
||||
sbom: ${{ inputs.sbom }}
|
||||
|
||||
merge:
|
||||
name: Merge ${{ inputs.image }} manifest
|
||||
runs-on: ubuntu-22.04
|
||||
needs: build
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Compute base tags
|
||||
id: meta_base
|
||||
uses: ./.github/actions/docker-image-tags
|
||||
with:
|
||||
image: ${{ inputs.image }}
|
||||
ref-name: ${{ env.RELEVANT_REF_NAME }}
|
||||
- name: Merge manifests
|
||||
uses: ./.github/actions/docker-merge-manifest
|
||||
with:
|
||||
base-tags: ${{ steps.meta_base.outputs.tags }}
|
||||
archs: "amd64 arm64"
|
||||
2
.github/workflows/check-package-versions.yml
vendored
2
.github/workflows/check-package-versions.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
228
.github/workflows/e2e-tests.yml
vendored
Normal file
228
.github/workflows/e2e-tests.yml
vendored
Normal file
@@ -0,0 +1,228 @@
|
||||
name: End-to-End Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'end-to-end') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
env:
|
||||
GITHUB_REPO_NAME: ${{ github.repository }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install poetry via pipx
|
||||
uses: abatilo/actions-poetry@v4
|
||||
with:
|
||||
poetry-version: 2.1.3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'poetry'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xauth xvfb libgbm1 libasound2t64 netcat-openbsd
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend/package-lock.json'
|
||||
|
||||
- name: Setup environment for end-to-end tests
|
||||
run: |
|
||||
# Create test results directory
|
||||
mkdir -p test-results
|
||||
|
||||
# Create downloads directory for OpenHands (use a directory in the home folder)
|
||||
mkdir -p $HOME/downloads
|
||||
sudo chown -R $USER:$USER $HOME/downloads
|
||||
sudo chmod -R 755 $HOME/downloads
|
||||
|
||||
- name: Build OpenHands
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
INSTALL_DOCKER: 1
|
||||
RUNTIME: docker
|
||||
FRONTEND_PORT: 12000
|
||||
FRONTEND_HOST: 0.0.0.0
|
||||
BACKEND_HOST: 0.0.0.0
|
||||
BACKEND_PORT: 3000
|
||||
ENABLE_BROWSER: true
|
||||
INSTALL_PLAYWRIGHT: 1
|
||||
run: |
|
||||
# Fix poetry.lock file if needed
|
||||
echo "Fixing poetry.lock file if needed..."
|
||||
poetry lock
|
||||
|
||||
# Build OpenHands using make build
|
||||
echo "Running make build..."
|
||||
make build
|
||||
|
||||
# Install Chromium Headless Shell for Playwright (needed for pytest-playwright)
|
||||
echo "Installing Chromium Headless Shell for Playwright..."
|
||||
poetry run playwright install chromium-headless-shell
|
||||
|
||||
# Verify Playwright browsers are installed (for e2e tests only)
|
||||
echo "Verifying Playwright browsers installation for e2e tests..."
|
||||
BROWSER_CHECK=$(poetry run python tests/e2e/check_playwright.py 2>/dev/null)
|
||||
|
||||
if [ "$BROWSER_CHECK" != "chromium_found" ]; then
|
||||
echo "ERROR: Chromium browser not found or not working for e2e tests"
|
||||
echo "$BROWSER_CHECK"
|
||||
exit 1
|
||||
else
|
||||
echo "Playwright browsers are properly installed for e2e tests."
|
||||
fi
|
||||
|
||||
# Docker runtime will handle workspace directory creation
|
||||
|
||||
# Start the application using make run with custom parameters and reduced logging
|
||||
echo "Starting OpenHands using make run..."
|
||||
# Set environment variables to reduce logging verbosity
|
||||
export PYTHONUNBUFFERED=1
|
||||
export LOG_LEVEL=WARNING
|
||||
export UVICORN_LOG_LEVEL=warning
|
||||
export OPENHANDS_LOG_LEVEL=WARNING
|
||||
FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 make run > /tmp/openhands-e2e-test.log 2>&1 &
|
||||
|
||||
# Store the PID of the make run process
|
||||
MAKE_PID=$!
|
||||
echo "OpenHands started with PID: $MAKE_PID"
|
||||
|
||||
# Wait for the application to start
|
||||
echo "Waiting for OpenHands to start..."
|
||||
max_attempts=15
|
||||
attempt=1
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
echo "Checking if OpenHands is running (attempt $attempt of $max_attempts)..."
|
||||
|
||||
# Check if the process is still running
|
||||
if ! ps -p $MAKE_PID > /dev/null; then
|
||||
echo "ERROR: OpenHands process has terminated unexpectedly"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if frontend port is open
|
||||
if nc -z localhost 12000; then
|
||||
# Verify we can get HTML content
|
||||
if curl -s http://localhost:12000 | grep -q "<html"; then
|
||||
echo "SUCCESS: OpenHands is running and serving HTML content on port 12000"
|
||||
break
|
||||
else
|
||||
echo "Port 12000 is open but not serving HTML content yet"
|
||||
fi
|
||||
else
|
||||
echo "Frontend port 12000 is not open yet"
|
||||
fi
|
||||
|
||||
# Show log output on each attempt
|
||||
echo "Recent log output:"
|
||||
tail -n 20 /tmp/openhands-e2e-test.log
|
||||
|
||||
# Wait before next attempt
|
||||
echo "Waiting 10 seconds before next check..."
|
||||
sleep 10
|
||||
attempt=$((attempt + 1))
|
||||
|
||||
# Exit if we've reached the maximum number of attempts
|
||||
if [ $attempt -gt $max_attempts ]; then
|
||||
echo "ERROR: OpenHands failed to start after $max_attempts attempts"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Final verification that the app is running
|
||||
if ! nc -z localhost 12000 || ! curl -s http://localhost:12000 | grep -q "<html"; then
|
||||
echo "ERROR: OpenHands is not running properly on port 12000"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Print success message
|
||||
echo "OpenHands is running successfully on port 12000"
|
||||
|
||||
- name: Run end-to-end tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.E2E_TEST_GITHUB_TOKEN }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
run: |
|
||||
# Check if the application is running
|
||||
if ! nc -z localhost 12000; then
|
||||
echo "ERROR: OpenHands is not running on port 12000"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the tests with detailed output
|
||||
cd tests/e2e
|
||||
poetry run python -m pytest \
|
||||
test_settings.py::test_github_token_configuration \
|
||||
test_conversation.py::test_conversation_start \
|
||||
test_browsing_catchphrase.py::test_browsing_catchphrase \
|
||||
test_multi_conversation_resume.py::test_multi_conversation_resume \
|
||||
-v --no-header --capture=no --timeout=900
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: playwright-report
|
||||
path: tests/e2e/test-results/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload OpenHands logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: openhands-logs
|
||||
path: |
|
||||
/tmp/openhands-e2e-test.log
|
||||
/tmp/openhands-e2e-build.log
|
||||
/tmp/openhands-backend.log
|
||||
/tmp/openhands-frontend.log
|
||||
/tmp/backend-health-check.log
|
||||
/tmp/frontend-check.log
|
||||
/tmp/vite-config.log
|
||||
/tmp/makefile-contents.log
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
# Stop OpenHands processes
|
||||
echo "Stopping OpenHands processes..."
|
||||
pkill -f "python -m openhands.server" || true
|
||||
pkill -f "npm run dev" || true
|
||||
pkill -f "make run" || true
|
||||
|
||||
# Print process status for debugging
|
||||
echo "Checking if any OpenHands processes are still running:"
|
||||
ps aux | grep -E "openhands|npm run dev" || true
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@v4
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
|
||||
10
.github/workflows/fe-e2e-tests.yml
vendored
10
.github/workflows/fe-e2e-tests.yml
vendored
@@ -17,20 +17,18 @@ concurrency:
|
||||
jobs:
|
||||
fe-e2e-test:
|
||||
name: FE E2E Tests
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
@@ -41,7 +39,7 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
run: npx playwright test --project=chromium
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
8
.github/workflows/fe-unit-tests.yml
vendored
8
.github/workflows/fe-unit-tests.yml
vendored
@@ -21,20 +21,18 @@ jobs:
|
||||
# Run frontend unit tests
|
||||
fe-test:
|
||||
name: FE Unit Tests
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
260
.github/workflows/ghcr-build.yml
vendored
260
.github/workflows/ghcr-build.yml
vendored
@@ -1,13 +1,17 @@
|
||||
# Workflow that builds and pushes the OpenHands app and enterprise Docker images to ghcr.io.
|
||||
# Per-image build logic lives in .github/workflows/_build-image.yml.
|
||||
# Workflow that builds, tests and then pushes the OpenHands and runtime docker images to the ghcr.io repository
|
||||
name: Docker
|
||||
|
||||
# Always run on "main"
|
||||
# Always run on tags
|
||||
# Always run on PRs
|
||||
# Can also be triggered manually
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "saas-rel-*"
|
||||
- "oss-rel-*"
|
||||
tags:
|
||||
- "*"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -16,45 +20,245 @@ on:
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
# PR events share a group so pushes supersede each other; each commit on a release branch gets its own group.
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build_app:
|
||||
name: App
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
uses: ./.github/workflows/_build-image.yml
|
||||
with:
|
||||
image: ghcr.io/openhands/openhands
|
||||
dockerfile: containers/app/Dockerfile
|
||||
env:
|
||||
RELEVANT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
build_enterprise:
|
||||
name: Enterprise
|
||||
jobs:
|
||||
define-matrix:
|
||||
runs-on: blacksmith
|
||||
outputs:
|
||||
base_image: ${{ steps.define-base-images.outputs.base_image }}
|
||||
steps:
|
||||
- name: Define base images
|
||||
shell: bash
|
||||
id: define-base-images
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
|
||||
]')
|
||||
else
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu" }
|
||||
]')
|
||||
fi
|
||||
echo "base_image=$json" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Builds the OpenHands Docker images
|
||||
ghcr_build_app:
|
||||
name: Build App Image
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.7.0
|
||||
with:
|
||||
image: tonistiigi/binfmt:latest
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Lowercase Repository Owner
|
||||
run: |
|
||||
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Build and push app image
|
||||
if: "!github.event.pull_request.head.repo.fork"
|
||||
run: |
|
||||
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
|
||||
|
||||
# Builds the runtime Docker images
|
||||
ghcr_build_runtime:
|
||||
name: Build Runtime Image
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: define-matrix
|
||||
strategy:
|
||||
matrix:
|
||||
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.7.0
|
||||
with:
|
||||
image: tonistiigi/binfmt:latest
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
|
||||
- name: Create source distribution and Dockerfile
|
||||
run: poetry run python3 -m openhands.runtime.utils.runtime_build --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
|
||||
- name: Lowercase Repository Owner
|
||||
run: |
|
||||
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Short SHA
|
||||
run: |
|
||||
echo SHORT_SHA=$(git rev-parse --short "$RELEVANT_SHA") >> $GITHUB_ENV
|
||||
- name: Determine docker build params
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
|
||||
|
||||
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
|
||||
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
|
||||
echo "DOCKER_PLATFORM=$(echo "$DOCKER_BUILD_JSON" | jq -r '.platform')" >> $GITHUB_ENV
|
||||
echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
|
||||
- name: Build and push runtime image ${{ matrix.base_image.image }}
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_TAGS }}
|
||||
platforms: ${{ env.DOCKER_PLATFORM }}
|
||||
# Caching directives to boost performance
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }}
|
||||
cache-to: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }},mode=max
|
||||
build-args: ${{ env.DOCKER_BUILD_ARGS }}
|
||||
context: containers/runtime
|
||||
provenance: false
|
||||
# Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
|
||||
- name: Build runtime image ${{ matrix.base_image.image }} for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
context: containers/runtime
|
||||
- name: Upload runtime source for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
path: containers/runtime
|
||||
|
||||
ghcr_build_enterprise:
|
||||
name: Push Enterprise Image
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: [define-matrix, ghcr_build_app]
|
||||
# Do not build enterprise in forks
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
needs: build_app
|
||||
uses: ./.github/workflows/_build-image.yml
|
||||
with:
|
||||
image: ghcr.io/openhands/enterprise-server
|
||||
dockerfile: enterprise/Dockerfile
|
||||
extra-build-args: OPENHANDS_VERSION=sha-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
provenance: true
|
||||
sbom: true
|
||||
buildx-driver-opts: network=host
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
# Set up Docker Buildx for better performance
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/openhands/enterprise-server
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
type=sha,format=long
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
flavor: |
|
||||
latest=auto
|
||||
prefix=
|
||||
suffix=
|
||||
env:
|
||||
DOCKER_METADATA_PR_HEAD_SHA: true
|
||||
- name: Determine app image tag
|
||||
shell: bash
|
||||
run: |
|
||||
# Use the commit SHA to pin the exact app image built by ghcr_build_app,
|
||||
# rather than a mutable branch tag like "main" which can serve stale cached layers.
|
||||
echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV
|
||||
- name: Build and push Docker image
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
file: enterprise/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
OPENHANDS_VERSION=${{ env.OPENHANDS_DOCKER_TAG }}
|
||||
platforms: linux/amd64
|
||||
# Add build provenance
|
||||
provenance: true
|
||||
# Add build attestations for better security
|
||||
sbom: true
|
||||
|
||||
# "All Runtime Tests Passed" is a required job for PRs to merge
|
||||
# We can remove this once the config changes
|
||||
runtime_tests_check_success:
|
||||
name: All Runtime Tests Passed
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: All tests passed
|
||||
run: echo "All runtime tests have passed successfully!"
|
||||
|
||||
update_pr_description:
|
||||
name: Update PR Description
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'
|
||||
needs: build_app
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [ghcr_build_runtime]
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get short SHA
|
||||
id: short_sha
|
||||
run: echo "SHORT_SHA=$(echo ${{ github.event.pull_request.head.sha }} | cut -c1-7)" >> "$GITHUB_OUTPUT"
|
||||
run: echo "SHORT_SHA=$(echo ${{ github.event.pull_request.head.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update PR Description
|
||||
env:
|
||||
@@ -65,4 +269,4 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Updating PR description with Docker and uvx commands"
|
||||
bash "${GITHUB_WORKSPACE}/.github/scripts/update_pr_description.sh"
|
||||
bash ${GITHUB_WORKSPACE}/.github/scripts/update_pr_description.sh
|
||||
|
||||
19
.github/workflows/lint-fix.yml
vendored
19
.github/workflows/lint-fix.yml
vendored
@@ -9,12 +9,12 @@ jobs:
|
||||
lint-fix-frontend:
|
||||
if: github.event.label.name == 'lint-fix'
|
||||
name: Fix frontend linting issues
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -22,14 +22,13 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
run: |
|
||||
cd frontend
|
||||
npm install --frozen-lockfile
|
||||
- name: Generate i18n and route types
|
||||
run: |
|
||||
cd frontend
|
||||
@@ -59,12 +58,12 @@ jobs:
|
||||
lint-fix-python:
|
||||
if: github.event.label.name == 'lint-fix'
|
||||
name: Fix Python linting issues
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -72,7 +71,7 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
|
||||
27
.github/workflows/lint.yml
vendored
27
.github/workflows/lint.yml
vendored
@@ -19,35 +19,34 @@ jobs:
|
||||
# Run lint on the frontend code
|
||||
lint-frontend:
|
||||
name: Lint frontend
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
run: |
|
||||
cd frontend
|
||||
npm install --frozen-lockfile
|
||||
- name: Lint, TypeScript compilation, and translation checks
|
||||
run: |
|
||||
cd frontend
|
||||
npm run lint
|
||||
npm run make-i18n && npx tsc
|
||||
npm run make-i18n && tsc
|
||||
npm run check-translation-completeness
|
||||
|
||||
# Run lint on the python code
|
||||
lint-python:
|
||||
name: Lint python
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
@@ -58,13 +57,13 @@ jobs:
|
||||
|
||||
lint-enterprise-python:
|
||||
name: Lint enterprise python
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
|
||||
8
.github/workflows/npm-publish-ui.yml
vendored
8
.github/workflows/npm-publish-ui.yml
vendored
@@ -18,7 +18,7 @@ concurrency:
|
||||
jobs:
|
||||
check-version:
|
||||
name: Check if version has changed
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
current-version: ${{ steps.version-check.outputs.current-version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2 # Need previous commit to compare
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should-publish == 'true'
|
||||
defaults:
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
433
.github/workflows/openhands-resolver.yml
vendored
Normal file
433
.github/workflows/openhands-resolver.yml
vendored
Normal file
@@ -0,0 +1,433 @@
|
||||
name: Auto-Fix Tagged Issue with OpenHands
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
max_iterations:
|
||||
required: false
|
||||
type: number
|
||||
default: 50
|
||||
macro:
|
||||
required: false
|
||||
type: string
|
||||
default: "@openhands-agent"
|
||||
target_branch:
|
||||
required: false
|
||||
type: string
|
||||
default: "main"
|
||||
description: "Target branch to pull and create PR against"
|
||||
pr_type:
|
||||
required: false
|
||||
type: string
|
||||
default: "draft"
|
||||
description: "The PR type that is going to be created (draft, ready)"
|
||||
LLM_MODEL:
|
||||
required: false
|
||||
type: string
|
||||
default: "anthropic/claude-sonnet-4-20250514"
|
||||
LLM_API_VERSION:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
base_container_image:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
description: "Custom sandbox env"
|
||||
runner:
|
||||
required: false
|
||||
type: string
|
||||
default: "ubuntu-latest"
|
||||
secrets:
|
||||
LLM_MODEL:
|
||||
required: false
|
||||
LLM_API_KEY:
|
||||
required: true
|
||||
LLM_BASE_URL:
|
||||
required: false
|
||||
PAT_TOKEN:
|
||||
required: false
|
||||
PAT_USERNAME:
|
||||
required: false
|
||||
|
||||
issues:
|
||||
types: [labeled]
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
auto-fix:
|
||||
if: |
|
||||
github.event_name == 'workflow_call' ||
|
||||
github.event.label.name == 'fix-me' ||
|
||||
github.event.label.name == 'fix-me-experimental' ||
|
||||
(
|
||||
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||
contains(github.event.comment.body, inputs.macro || '@openhands-agent') &&
|
||||
(github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER')
|
||||
) ||
|
||||
|
||||
(github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, inputs.macro || '@openhands-agent') &&
|
||||
(github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER')
|
||||
)
|
||||
)
|
||||
runs-on: "${{ inputs.runner || '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: Upgrade pip
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
- name: Get latest versions and create requirements.txt
|
||||
run: |
|
||||
python -m pip index versions openhands-ai > openhands_versions.txt
|
||||
OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()')
|
||||
|
||||
# Create a new requirements.txt locally within the workflow, ensuring no reference to the repo's file
|
||||
echo "openhands-ai==${OPENHANDS_VERSION}" > /tmp/requirements.txt
|
||||
cat /tmp/requirements.txt
|
||||
|
||||
- name: Cache pip dependencies
|
||||
if: |
|
||||
!(
|
||||
github.event.label.name == 'fix-me-experimental' ||
|
||||
(
|
||||
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||
contains(github.event.comment.body, '@openhands-agent-exp')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, '@openhands-agent-exp')
|
||||
)
|
||||
)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
|
||||
key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
|
||||
|
||||
- name: Check required environment variables
|
||||
env:
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
|
||||
PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
PAT_USERNAME: ${{ secrets.PAT_USERNAME }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
required_vars=("LLM_API_KEY")
|
||||
for var in "${required_vars[@]}"; do
|
||||
if [ -z "${!var}" ]; then
|
||||
echo "Error: Required environment variable $var is not set."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check optional variables and warn about fallbacks
|
||||
if [ -z "$LLM_BASE_URL" ]; then
|
||||
echo "Warning: LLM_BASE_URL is not set, will use default API endpoint"
|
||||
fi
|
||||
|
||||
if [ -z "$PAT_TOKEN" ]; then
|
||||
echo "Warning: PAT_TOKEN is not set, falling back to GITHUB_TOKEN"
|
||||
fi
|
||||
|
||||
if [ -z "$PAT_USERNAME" ]; then
|
||||
echo "Warning: PAT_USERNAME is not set, will use openhands-agent"
|
||||
fi
|
||||
|
||||
- name: Set environment variables
|
||||
env:
|
||||
REVIEW_BODY: ${{ github.event.review.body || '' }}
|
||||
run: |
|
||||
# Handle pull request events first
|
||||
if [ -n "${{ github.event.pull_request.number }}" ]; then
|
||||
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
|
||||
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
|
||||
# Handle pull request review events
|
||||
elif [ -n "$REVIEW_BODY" ]; then
|
||||
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
|
||||
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
|
||||
# Handle issue comment events that reference a PR
|
||||
elif [ -n "${{ github.event.issue.pull_request }}" ]; then
|
||||
echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
|
||||
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
|
||||
# Handle regular issue events
|
||||
else
|
||||
echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
|
||||
echo "ISSUE_TYPE=issue" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
if [ -n "$REVIEW_BODY" ]; then
|
||||
echo "COMMENT_ID=${{ github.event.review.id || 'None' }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "COMMENT_ID=${{ github.event.comment.id || 'None' }}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
echo "MAX_ITERATIONS=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV
|
||||
echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.PAT_TOKEN || github.token }}" >> $GITHUB_ENV
|
||||
echo "SANDBOX_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $GITHUB_ENV
|
||||
|
||||
# Set branch variables
|
||||
echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Comment on issue with start message
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
script: |
|
||||
const issueType = process.env.ISSUE_TYPE;
|
||||
github.rest.issues.createComment({
|
||||
issue_number: ${{ env.ISSUE_NUMBER }},
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `[OpenHands](https://github.com/OpenHands/OpenHands) started fixing the ${issueType}! You can monitor the progress [here](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`
|
||||
});
|
||||
|
||||
- name: Install OpenHands
|
||||
id: install_openhands
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body || '' }}
|
||||
REVIEW_BODY: ${{ github.event.review.body || '' }}
|
||||
LABEL_NAME: ${{ github.event.label.name || '' }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
with:
|
||||
script: |
|
||||
const commentBody = process.env.COMMENT_BODY.trim();
|
||||
const reviewBody = process.env.REVIEW_BODY.trim();
|
||||
const labelName = process.env.LABEL_NAME.trim();
|
||||
const eventName = process.env.EVENT_NAME.trim();
|
||||
// Check conditions
|
||||
const isExperimentalLabel = labelName === "fix-me-experimental";
|
||||
const isIssueCommentExperimental =
|
||||
(eventName === "issue_comment" || eventName === "pull_request_review_comment") &&
|
||||
commentBody.includes("@openhands-agent-exp");
|
||||
const isReviewCommentExperimental =
|
||||
eventName === "pull_request_review" && reviewBody.includes("@openhands-agent-exp");
|
||||
|
||||
// Set output variable
|
||||
core.setOutput('isExperimental', isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental);
|
||||
|
||||
// Perform package installation
|
||||
if (isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental) {
|
||||
console.log("Installing experimental OpenHands...");
|
||||
|
||||
await exec.exec("pip install git+https://github.com/openhands/openhands.git");
|
||||
} else {
|
||||
console.log("Installing from requirements.txt...");
|
||||
|
||||
await exec.exec("pip install -r /tmp/requirements.txt");
|
||||
}
|
||||
|
||||
- name: Attempt to resolve issue
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
|
||||
PYTHONPATH: ""
|
||||
run: |
|
||||
cd /tmp && python -m openhands.resolver.resolve_issue \
|
||||
--selected-repo ${{ github.repository }} \
|
||||
--issue-number ${{ env.ISSUE_NUMBER }} \
|
||||
--issue-type ${{ env.ISSUE_TYPE }} \
|
||||
--max-iterations ${{ env.MAX_ITERATIONS }} \
|
||||
--comment-id ${{ env.COMMENT_ID }} \
|
||||
--is-experimental ${{ steps.install_openhands.outputs.isExperimental }}
|
||||
|
||||
- name: Check resolution result
|
||||
id: check_result
|
||||
run: |
|
||||
if cd /tmp && grep -q '"success":true' output/output.jsonl; then
|
||||
echo "RESOLUTION_SUCCESS=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "RESOLUTION_SUCCESS=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Upload output.jsonl as artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always() # Upload even if the previous steps fail
|
||||
with:
|
||||
name: resolver-output
|
||||
path: /tmp/output/output.jsonl
|
||||
retention-days: 30 # Keep the artifact for 30 days
|
||||
|
||||
- name: Create draft PR or push branch
|
||||
if: always() # Create PR or branch even if the previous steps fail
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
|
||||
PYTHONPATH: ""
|
||||
run: |
|
||||
if [ "${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}" == "true" ]; then
|
||||
cd /tmp && python -m openhands.resolver.send_pull_request \
|
||||
--issue-number ${{ env.ISSUE_NUMBER }} \
|
||||
--target-branch ${{ env.TARGET_BRANCH }} \
|
||||
--pr-type ${{ inputs.pr_type || 'draft' }} \
|
||||
--reviewer ${{ github.actor }} | tee pr_result.txt && \
|
||||
grep "PR created" pr_result.txt | sed 's/.*\///g' > pr_number.txt
|
||||
else
|
||||
cd /tmp && python -m openhands.resolver.send_pull_request \
|
||||
--issue-number ${{ env.ISSUE_NUMBER }} \
|
||||
--pr-type branch \
|
||||
--send-on-failure | tee branch_result.txt && \
|
||||
grep "branch created" branch_result.txt | sed 's/.*\///g; s/.expand=1//g' > branch_name.txt
|
||||
fi
|
||||
|
||||
# Step leaves comment for when agent is invoked on PR
|
||||
- name: Analyze Push Logs (Updated PR or No Changes) # Skip comment if PR update was successful OR leave comment if the agent made no code changes
|
||||
uses: actions/github-script@v7
|
||||
if: always()
|
||||
env:
|
||||
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
|
||||
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
|
||||
with:
|
||||
github-token: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const issueNumber = process.env.ISSUE_NUMBER;
|
||||
let logContent = '';
|
||||
|
||||
try {
|
||||
logContent = fs.readFileSync('/tmp/pr_result.txt', 'utf8').trim();
|
||||
} catch (error) {
|
||||
console.error('Error reading pr_result.txt file:', error);
|
||||
}
|
||||
|
||||
const noChangesMessage = `No changes to commit for issue #${issueNumber}. Skipping commit.`;
|
||||
|
||||
// Check logs from send_pull_request.py (pushes code to GitHub)
|
||||
if (logContent.includes("Updated pull request")) {
|
||||
console.log("Updated pull request found. Skipping comment.");
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
} else if (logContent.includes(noChangesMessage)) {
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `The workflow to fix this issue encountered an error. Openhands failed to create any code changes.`
|
||||
});
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
}
|
||||
|
||||
# Step leaves comment for when agent is invoked on issue
|
||||
- name: Comment on issue # Comment link to either PR or branch created by agent
|
||||
uses: actions/github-script@v7
|
||||
if: always() # Comment on issue even if the previous steps fail
|
||||
env:
|
||||
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
|
||||
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
|
||||
RESOLUTION_SUCCESS: ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}
|
||||
with:
|
||||
github-token: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const issueNumber = process.env.ISSUE_NUMBER;
|
||||
const success = process.env.RESOLUTION_SUCCESS === 'true';
|
||||
|
||||
let prNumber = '';
|
||||
let branchName = '';
|
||||
let resultExplanation = '';
|
||||
|
||||
try {
|
||||
if (success) {
|
||||
prNumber = fs.readFileSync('/tmp/pr_number.txt', 'utf8').trim();
|
||||
} else {
|
||||
branchName = fs.readFileSync('/tmp/branch_name.txt', 'utf8').trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (!success){
|
||||
// Read result_explanation from JSON file for failed resolution
|
||||
const outputFilePath = path.resolve('/tmp/output/output.jsonl');
|
||||
if (fs.existsSync(outputFilePath)) {
|
||||
const outputContent = fs.readFileSync(outputFilePath, 'utf8');
|
||||
const jsonLines = outputContent.split('\n').filter(line => line.trim() !== '');
|
||||
|
||||
if (jsonLines.length > 0) {
|
||||
// First entry in JSON lines has the key 'result_explanation'
|
||||
const firstEntry = JSON.parse(jsonLines[0]);
|
||||
resultExplanation = firstEntry.result_explanation || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error){
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
|
||||
// Check "success" log from resolver output
|
||||
if (success && prNumber) {
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `A potential fix has been generated and a draft PR #${prNumber} has been created. Please review the changes.`
|
||||
});
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
} else if (!success && branchName) {
|
||||
let commentBody = `An attempt was made to automatically fix this issue, but it was unsuccessful. A branch named '${branchName}' has been created with the attempted changes. You can view the branch [here](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}). Manual intervention may be required.`;
|
||||
|
||||
if (resultExplanation) {
|
||||
commentBody += `\n\nAdditional details about the failure:\n${resultExplanation}`;
|
||||
}
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: commentBody
|
||||
});
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
}
|
||||
|
||||
# Leave error comment when both PR/Issue comment handling fail
|
||||
- name: Fallback Error Comment
|
||||
uses: actions/github-script@v7
|
||||
if: ${{ env.AGENT_RESPONDED == 'false' }} # Only run if no conditions were met in previous steps
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
|
||||
with:
|
||||
github-token: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
script: |
|
||||
const issueNumber = process.env.ISSUE_NUMBER;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `The workflow to fix this issue encountered an error. Please check the [workflow logs](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for more information.`
|
||||
});
|
||||
10
.github/workflows/pr-artifacts.yml
vendored
10
.github/workflows/pr-artifacts.yml
vendored
@@ -31,11 +31,11 @@ jobs:
|
||||
echo "is_fork=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.check-fork.outputs.is_fork == 'false'
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
|
||||
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
|
||||
|
||||
- name: Remove .pr/ directory
|
||||
id: remove
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
- name: Update PR comment after cleanup
|
||||
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Check for .pr/ directory
|
||||
id: check
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.check.outputs.exists == 'true'
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
|
||||
42
.github/workflows/pr-review-by-openhands.yml
vendored
42
.github/workflows/pr-review-by-openhands.yml
vendored
@@ -2,14 +2,12 @@
|
||||
name: PR Review by OpenHands
|
||||
|
||||
on:
|
||||
# Use pull_request for same-repo PRs so workflow changes can self-verify in PRs.
|
||||
# TEMPORARY MITIGATION (Clinejection hardening)
|
||||
#
|
||||
# We temporarily avoid `pull_request_target` here. We'll restore it after the PR review
|
||||
# workflow is fully hardened for untrusted execution.
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, labeled, review_requested]
|
||||
# Use pull_request_target for fork PRs.
|
||||
# The bot token used here is intentionally scoped to PR review operations,
|
||||
# so the remaining blast radius is bounded even though PR content is untrusted.
|
||||
pull_request_target:
|
||||
types: [opened, ready_for_review, labeled, review_requested]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -18,33 +16,13 @@ permissions:
|
||||
|
||||
jobs:
|
||||
pr-review:
|
||||
# Run on same-repo PRs via pull_request and on fork PRs via pull_request_target.
|
||||
# Trigger when one of the following conditions is met:
|
||||
# 1. A new non-draft PR is opened by a non-first-time contributor, OR
|
||||
# 2. A draft PR is converted to ready for review by a non-first-time contributor, OR
|
||||
# 3. The 'review-this' label is added, OR
|
||||
# 4. openhands-agent or all-hands-bot is requested as a reviewer
|
||||
# Note: FIRST_TIME_CONTRIBUTOR and NONE PRs require manual trigger via label/reviewer request.
|
||||
# Trigger logic:
|
||||
# 1. Route same-repo PRs through `pull_request` and fork PRs through `pull_request_target`
|
||||
# 2. Auto-trigger on `opened` / `ready_for_review` for non-first-time contributors
|
||||
# 3. Always allow manual triggers via `review-this` or reviewer request
|
||||
# The author association check is duplicated intentionally for both
|
||||
# auto-triggered actions (`opened` and `ready_for_review`).
|
||||
# Note: fork PRs will not have access to repository secrets under `pull_request`.
|
||||
# Skip forks to avoid noisy failures until we restore a hardened `pull_request_target` flow.
|
||||
if: |
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.head.repo.full_name != github.repository
|
||||
)
|
||||
) &&
|
||||
(
|
||||
(github.event.action == 'opened' && github.event.pull_request.draft == false && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR' && github.event.pull_request.author_association != 'NONE') ||
|
||||
(github.event.action == 'ready_for_review' && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR' && github.event.pull_request.author_association != 'NONE') ||
|
||||
(github.event.action == 'opened' && github.event.pull_request.draft == false) ||
|
||||
github.event.action == 'ready_for_review' ||
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'review-this') ||
|
||||
(
|
||||
github.event.action == 'review_requested' &&
|
||||
@@ -66,5 +44,5 @@ jobs:
|
||||
llm-base-url: https://llm-proxy.app.all-hands.dev
|
||||
review-style: roasted
|
||||
llm-api-key: ${{ secrets.LLM_API_KEY }}
|
||||
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
|
||||
github-token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
|
||||
lmnr-api-key: ${{ secrets.LMNR_SKILLS_API_KEY }}
|
||||
|
||||
6
.github/workflows/pr-review-evaluation.yml
vendored
6
.github/workflows/pr-review-evaluation.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
steps:
|
||||
- name: Download review trace artifact
|
||||
id: download-trace
|
||||
uses: dawidd6/action-download-artifact@v15
|
||||
uses: dawidd6/action-download-artifact@v6
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: pr-review-by-openhands.yml
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
# Always checkout main branch for security - cannot test script changes in PRs
|
||||
- name: Checkout extensions repository
|
||||
if: steps.check-trace.outputs.trace_exists == 'true'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: OpenHands/extensions
|
||||
path: extensions
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
--trace-file trace-info/laminar_trace_info.json
|
||||
|
||||
- name: Upload evaluation logs
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v5
|
||||
if: always() && steps.check-trace.outputs.trace_exists == 'true'
|
||||
with:
|
||||
name: pr-review-evaluation-${{ github.event.pull_request.number }}
|
||||
|
||||
28
.github/workflows/py-tests.yml
vendored
28
.github/workflows/py-tests.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
# Run python tests on Linux
|
||||
test-on-linux:
|
||||
name: Python Tests on Linux
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
env:
|
||||
INSTALL_DOCKER: "0" # Set to '0' to skip Docker installation
|
||||
strategy:
|
||||
@@ -30,22 +30,20 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Install tmux
|
||||
run: sudo apt-get update && sudo apt-get install -y tmux
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: "22.x"
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
@@ -60,8 +58,12 @@ jobs:
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -s ./tests/unit --cov=openhands --cov-branch
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
|
||||
- name: Run Runtime Tests with CLIRuntime
|
||||
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -n 5 --reruns 2 --reruns-delay 3 -s tests/runtime/test_bash.py --cov=openhands --cov-branch
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
|
||||
- name: Store coverage file
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: coverage-openhands
|
||||
path: |
|
||||
@@ -71,16 +73,16 @@ jobs:
|
||||
|
||||
test-enterprise:
|
||||
name: Enterprise Python Unit Tests
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
@@ -93,7 +95,7 @@ jobs:
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
|
||||
- name: Store coverage file
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: coverage-enterprise
|
||||
path: ".coverage.enterprise.${{ matrix.python_version }}"
|
||||
@@ -109,9 +111,9 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@v6
|
||||
id: download
|
||||
with:
|
||||
pattern: coverage-*
|
||||
|
||||
10
.github/workflows/pypi-release.yml
vendored
10
.github/workflows/pypi-release.yml
vendored
@@ -17,14 +17,14 @@ on:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-22.04
|
||||
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli' and don't start with 'cloud-'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli'
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'app server')
|
||||
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli') && !startsWith(github.ref, 'refs/tags/cloud-'))
|
||||
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli'))
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
- name: Install Poetry
|
||||
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -8,10 +8,10 @@ on:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: github.repository == 'OpenHands/OpenHands'
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
|
||||
59
.github/workflows/tag-image.yml
vendored
59
.github/workflows/tag-image.yml
vendored
@@ -1,59 +0,0 @@
|
||||
# Adds a git-tag name to existing Docker images.
|
||||
# Triggered when a tag is pushed: finds the images built at the tag's commit
|
||||
# (tagged `sha-<full>`) and adds the tag name as an alias for the same manifest.
|
||||
# Semver tags (X.Y.Z) also get X.Y, X, and latest aliases.
|
||||
# No rebuild — pure registry-side retag via `docker buildx imagetools create`.
|
||||
name: Tag Docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
retag:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
image:
|
||||
- ghcr.io/openhands/openhands
|
||||
- ghcr.io/openhands/enterprise-server
|
||||
steps:
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Compute tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ matrix.image }}
|
||||
flavor: latest=auto
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
- name: Add tags to existing image
|
||||
env:
|
||||
SRC: ${{ matrix.image }}:sha-${{ github.sha }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! docker buildx imagetools inspect "$SRC" > /dev/null 2>&1; then
|
||||
echo "::error::Source image $SRC does not exist. The Docker workflow for commit ${{ github.sha }} may not have completed successfully. Re-run this workflow once the build finishes."
|
||||
exit 1
|
||||
fi
|
||||
args=()
|
||||
while IFS= read -r tag; do
|
||||
[[ -z "$tag" ]] && continue
|
||||
args+=(-t "$tag")
|
||||
done <<< "$TAGS"
|
||||
docker buildx imagetools create "${args[@]}" "$SRC"
|
||||
4
.github/workflows/ui-build.yml
vendored
4
.github/workflows/ui-build.yml
vendored
@@ -19,10 +19,10 @@ concurrency:
|
||||
jobs:
|
||||
ui-build:
|
||||
name: Build openhands-ui
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: "openhands-ui/.bun-version"
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check if welcome comment already exists
|
||||
id: check_comment
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Leave welcome comment
|
||||
if: steps.check_comment.outputs.result == 'false'
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const repoUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}`;
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -254,6 +254,10 @@ run_instance_logs
|
||||
|
||||
runtime_*.tar
|
||||
|
||||
# docker build
|
||||
containers/runtime/Dockerfile
|
||||
containers/runtime/project.tar.gz
|
||||
containers/runtime/code
|
||||
**/node_modules/
|
||||
|
||||
# test results
|
||||
|
||||
93
AGENTS.md
93
AGENTS.md
@@ -1,6 +1,21 @@
|
||||
This repository contains the code for OpenHands, an automated AI software engineer. It has a Python backend
|
||||
(in the `openhands` directory) and React frontend (in the `frontend` directory).
|
||||
|
||||
|
||||
## Repository Memory
|
||||
- Legacy `/api/settings` responses can bridge to the SDK by returning `sdk_settings_schema` from `openhands.sdk.settings` when that package is available. Use this as the compatibility handoff while V1 settings work moves into the SDK and newer clients.
|
||||
- The legacy LLM settings screen now renders SDK-backed sections from `sdk_settings_schema` and reads/writes values through the generic settings blob. The canonical backend field is `agent_settings`; `sdk_settings_values` is a compatibility alias for older callers.
|
||||
- In enterprise mode, persist the generic SDK settings blob in `agent_settings` on `enterprise/storage/org_member.py` and `enterprise/storage/user_settings.py`. Keep a `sdk_settings_values` alias only for compatibility with older tests/callers.
|
||||
- Persisted SaaS `agent_settings` should carry a `schema_version` and canonical dotted keys, but should not duplicate secret SDK values like `llm.api_key` in plaintext JSON. Reconstruct those from encrypted legacy columns on load, and backfill/migrate rows on read/write.
|
||||
- The frontend settings query still normalizes canonical backend fields (`agent_settings`, `agent_settings_schema`) back into legacy `sdk_settings_values` / `sdk_settings_schema` for existing settings screens. Strip both canonical and legacy schema/value blobs from save payloads so redacted GET metadata is never POSTed back.
|
||||
|
||||
- The SDK settings schema now uses neutral metadata (`value_type`, `prominence`, `choices`, `depends_on`) instead of legacy UI-only fields like `widget`, `advanced`, or `placeholder`. Frontend helpers should derive control types from `value_type`/`choices`, and dotted `sdk_settings_values` may include structured JSON objects/arrays.
|
||||
- When constructing runtime `LLM`s for `openhands/*` models, keep explicit user-provided `llm.base_url` overrides, but prefer the app's `openhands_provider_base_url` when the user did not set one. Newer SDK defaults may populate an OpenHands proxy URL automatically, so check persisted user settings rather than `AgentSettings.llm.base_url` alone.
|
||||
- SDK `AgentSettings` sections are: `llm`, `condenser`, `verification`. The `verification` section merges former `critic` + `security` settings into one `VerificationSettings` model. Backward-compat property accessors (`.critic`, `.security`, `.enabled`, `.mode`, `.threshold`) and type aliases (`CriticSettings`, `SecuritySettings`) are preserved. Do NOT subclass `AgentSettings` in OpenHands — use it directly.
|
||||
|
||||
|
||||
|
||||
|
||||
## General Setup:
|
||||
To set up the entire repo, including frontend and backend, run `make build`.
|
||||
You don't need to do this unless the user asks you to, or if you're trying to run the entire application.
|
||||
@@ -13,21 +28,13 @@ export RUNTIME=local
|
||||
make build && make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 &> /tmp/openhands-log.txt &
|
||||
```
|
||||
|
||||
Local run troubleshooting notes:
|
||||
- If the backend fails with `nc: command not found`, install `netcat-openbsd`.
|
||||
- If local runtime startup fails with `duplicate session: test-session`, clear the stale tmux session on the default socket: `tmux -S /tmp/tmux-$(id -u)/default kill-session -t test-session`.
|
||||
- Local runtime browser startup expects Playwright browsers under `~/.cache/playwright`; if needed run `PLAYWRIGHT_BROWSERS_PATH=$HOME/.cache/playwright poetry run playwright install chromium`.
|
||||
- In this sandbox environment, an inherited `SESSION_API_KEY` can make `/api/v1/settings` return 401 in the browser. Unset it before `make run` when you want to use the local web UI directly.
|
||||
- In this sandbox, `frontend`'s `npm run dev:mock` / `dev:mock:saas` can start but still be awkward to browse through the work-host proxy. For PR QA screenshots, a reliable fallback is to `npm run build` with the desired `VITE_MOCK_*` env, then serve `build/` with a tiny custom HTTP server that returns the minimal mock JSON endpoints needed by the settings page.
|
||||
|
||||
|
||||
IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-pre-commit-hooks` to ensure pre-commit hooks are properly installed.
|
||||
|
||||
Before pushing any changes, you MUST ensure that any lint errors or simple test errors have been fixed.
|
||||
|
||||
* If you've made changes to the backend, you should run `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml` (this will run on staged files).
|
||||
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
|
||||
* If you've made changes to the VSCode extension, you should run `cd openhands/app_server/integrations/vscode && npm run lint:fix && npm run compile ; cd ../../..`
|
||||
* If you've made changes to the VSCode extension, you should run `cd openhands/integrations/vscode && npm run lint:fix && npm run compile ; cd ../../..`
|
||||
|
||||
The pre-commit hooks MUST pass successfully before pushing any changes to the repository. This is a mandatory requirement to maintain code quality and consistency.
|
||||
|
||||
@@ -44,42 +51,6 @@ then re-run the command to ensure it passes. Common issues include:
|
||||
- Be especially careful with `git reset --hard` after staging files, as it will remove accidentally staged files
|
||||
- When remote has new changes, use `git fetch upstream && git rebase upstream/<branch>` on the same branch
|
||||
|
||||
## Lockfile Regeneration (Preserve Original Tool Versions)
|
||||
|
||||
When regenerating lockfiles (poetry.lock, uv.lock, etc.), you MUST use the same tool version that originally generated the lockfile to avoid unnecessary diff noise. Each lockfile contains a version header indicating which tool version was used.
|
||||
|
||||
### Poetry (poetry.lock)
|
||||
|
||||
1. Extract the version from the lockfile header:
|
||||
```bash
|
||||
POETRY_VERSION=$(grep -m1 "^# This file is automatically @generated by Poetry" poetry.lock | sed 's/.*Poetry \([0-9.]*\).*/\1/')
|
||||
```
|
||||
2. If a version is found, install that specific version:
|
||||
```bash
|
||||
pipx install poetry==$POETRY_VERSION --force
|
||||
```
|
||||
3. Then regenerate the lockfile:
|
||||
```bash
|
||||
poetry lock --no-update
|
||||
```
|
||||
|
||||
### uv (uv.lock)
|
||||
|
||||
1. Extract the version from the lockfile header:
|
||||
```bash
|
||||
UV_VERSION=$(grep -m1 "^# This file was autogenerated by uv" uv.lock | sed 's/.*uv version \([0-9.]*\).*/\1/')
|
||||
```
|
||||
2. If a version is found, install that specific version:
|
||||
```bash
|
||||
pipx install uv==$UV_VERSION --force
|
||||
```
|
||||
3. Then regenerate the lockfile:
|
||||
```bash
|
||||
uv lock
|
||||
```
|
||||
|
||||
This ensures that lockfile updates only contain actual dependency changes, not tool version migration artifacts.
|
||||
|
||||
## PR-Specific Artifacts (`.pr/` directory)
|
||||
|
||||
When working on a PR that requires design documents, scripts meant for development-only, or other temporary artifacts that should NOT be merged to main, store them in a `.pr/` directory at the repository root.
|
||||
@@ -146,11 +117,9 @@ Frontend:
|
||||
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationSkills`)
|
||||
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
|
||||
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
|
||||
- For SaaS organization management screens, prefer deriving the selected organization from `useOrganizations()` plus the selected org ID store instead of adding a dedicated single-org fetch when only list-level fields (for example `name`) are needed.
|
||||
|
||||
|
||||
VSCode Extension:
|
||||
- Located in the `openhands/app_server/integrations/vscode` directory
|
||||
- Located in the `openhands/integrations/vscode` directory
|
||||
- Setup: Run `npm install` in the extension directory
|
||||
- Linting:
|
||||
- Run linting with fixes: `npm run lint:fix`
|
||||
@@ -236,7 +205,6 @@ Each integration follows a consistent pattern with service classes, storage mode
|
||||
- Database changes require careful migration planning in `enterprise/migrations/`
|
||||
- Always test changes in both OpenHands and enterprise contexts
|
||||
- Use the enterprise-specific Makefile commands for development
|
||||
- When the `openhands-ai` package (root project) version has been updated, run `poetry lock` in the `enterprise/` folder to update the version in the enterprise poetry lockfile.
|
||||
|
||||
**Enterprise Testing Best Practices:**
|
||||
|
||||
@@ -284,32 +252,6 @@ If you are starting a pull request (PR), please follow the template in `.github/
|
||||
|
||||
These details may or may not be useful for your current task.
|
||||
|
||||
### Conversation State Management
|
||||
|
||||
#### Agent State and Sandbox Status:
|
||||
The frontend uses `useAgentState` hook (`frontend/src/hooks/use-agent-state.ts`) to determine the current conversation state. This hook:
|
||||
- Returns `curAgentState` (AgentState enum) for UI state determination
|
||||
- Returns `isArchived` flag when `sandbox_status === "MISSING"` (archived conversations)
|
||||
- Prioritizes live WebSocket execution status over cached API data
|
||||
|
||||
#### Archived Conversations (sandbox_status === "MISSING"):
|
||||
When a conversation's sandbox is no longer available (archived):
|
||||
- `useAgentState` returns `AgentState.STOPPED` and `isArchived: true`
|
||||
- Chat input is replaced with an archived banner (`ArchivedBanner` component)
|
||||
- VS Code tab, Terminal, and Planner show read-only messages instead of loading states
|
||||
- All interactive elements that require a running sandbox are disabled
|
||||
|
||||
#### Testing useAgentState:
|
||||
When mocking `useAgentState` in tests, always include the `isArchived` property:
|
||||
```typescript
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: () => ({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
isArchived: false,
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
### Microagents
|
||||
|
||||
Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge and task-specific workflows. They are Markdown files that can include frontmatter for configuration.
|
||||
@@ -389,7 +331,6 @@ There are two main patterns for saving settings in the OpenHands frontend:
|
||||
**When to use each pattern:**
|
||||
- Use Pattern 1 (Immediate Save) for entity management where each item is independent
|
||||
- Use Pattern 2 (Manual Save) for configuration forms where settings are interdependent or need validation
|
||||
- Git provider tokens in the local/OSS integrations settings are managed through the V1 secrets endpoints (`POST`/`DELETE /api/v1/secrets/git-providers`). Do not reuse the logout flow for disconnecting tokens; `useLogout` is for actual app logout and still targets legacy OSS logout behavior.
|
||||
|
||||
### Adding New LLM Models
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ Full details in our [Development Guide](./Development.md).
|
||||
|
||||
- **[Frontend](./frontend/README.md)** - React application
|
||||
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
|
||||
- **[Agents](./openhands/agenthub/README.md)** - AI agent implementations
|
||||
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
|
||||
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
|
||||
|
||||
## What Can You Build?
|
||||
|
||||
@@ -16,7 +16,7 @@ open source community:
|
||||
|
||||
#### [Aider](https://github.com/paul-gauthier/aider)
|
||||
- License: Apache License 2.0
|
||||
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks.
|
||||
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks in [`agentskills utilities`](https://github.com/OpenHands/OpenHands/tree/main/openhands/runtime/plugins/agent_skills/utils/aider)
|
||||
|
||||
#### [BrowserGym](https://github.com/ServiceNow/BrowserGym)
|
||||
- License: Apache License 2.0
|
||||
|
||||
@@ -309,6 +309,16 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
|
||||
---
|
||||
|
||||
## Using Existing Docker Images
|
||||
|
||||
To reduce build time, you can use an existing runtime image:
|
||||
|
||||
```bash
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Help
|
||||
|
||||
```bash
|
||||
@@ -329,3 +339,4 @@ make help
|
||||
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
|
||||
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
|
||||
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
|
||||
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model
|
||||
|
||||
29
Makefile
29
Makefile
@@ -11,15 +11,7 @@ DEFAULT_WORKSPACE_DIR = "./workspace"
|
||||
DEFAULT_MODEL = "gpt-4o"
|
||||
CONFIG_FILE = config.toml
|
||||
PRE_COMMIT_CONFIG_PATH = "./dev_config/python/.pre-commit-config.yaml"
|
||||
PYTHON_MIN_VERSION = 3.12
|
||||
PYTHON_MAX_VERSION = 3.14
|
||||
PYTHON_CANDIDATES ?= python3.13 python3.12 python3
|
||||
PYTHON ?= $(shell for cmd in $(PYTHON_CANDIDATES); do \
|
||||
if command -v $$cmd > /dev/null 2>&1 && $$cmd -c 'import sys; raise SystemExit(0 if ((3, 12) <= sys.version_info[:2] < (3, 14)) else 1)' > /dev/null 2>&1; then \
|
||||
echo $$cmd; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
done)
|
||||
PYTHON_VERSION = 3.12
|
||||
KIND_CLUSTER_NAME = "local-hands"
|
||||
|
||||
# ANSI color codes
|
||||
@@ -71,10 +63,10 @@ check-system:
|
||||
|
||||
check-python:
|
||||
@echo "$(YELLOW)Checking Python installation...$(RESET)"
|
||||
@if [ -n "$(PYTHON)" ]; then \
|
||||
echo "$(BLUE)$$($(PYTHON) --version) is already installed (using $(PYTHON)).$(RESET)"; \
|
||||
@if command -v python$(PYTHON_VERSION) > /dev/null; then \
|
||||
echo "$(BLUE)$(shell python$(PYTHON_VERSION) --version) is already installed.$(RESET)"; \
|
||||
else \
|
||||
echo "$(RED)A compatible Python interpreter (>= $(PYTHON_MIN_VERSION), < $(PYTHON_MAX_VERSION)) is required. Please install Python 3.12 or 3.13 to continue.$(RESET)"; \
|
||||
echo "$(RED)Python $(PYTHON_VERSION) is not installed. Please install Python $(PYTHON_VERSION) to continue.$(RESET)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
@@ -126,34 +118,31 @@ check-tmux:
|
||||
|
||||
check-poetry:
|
||||
@echo "$(YELLOW)Checking Poetry installation...$(RESET)"
|
||||
@if [ -z "$(PYTHON)" ]; then \
|
||||
echo "$(RED)A compatible Python interpreter (>= $(PYTHON_MIN_VERSION), < $(PYTHON_MAX_VERSION)) is required. Please install Python 3.12 or 3.13 to continue.$(RESET)"; \
|
||||
exit 1; \
|
||||
elif command -v poetry > /dev/null; then \
|
||||
@if command -v poetry > /dev/null; then \
|
||||
POETRY_VERSION=$(shell poetry --version 2>&1 | sed -E 's/Poetry \(version ([0-9]+\.[0-9]+\.[0-9]+)\)/\1/'); \
|
||||
IFS='.' read -r -a POETRY_VERSION_ARRAY <<< "$$POETRY_VERSION"; \
|
||||
if [ $${POETRY_VERSION_ARRAY[0]} -gt 1 ] || ([ $${POETRY_VERSION_ARRAY[0]} -eq 1 ] && [ $${POETRY_VERSION_ARRAY[1]} -ge 8 ]); then \
|
||||
echo "$(BLUE)$(shell poetry --version) is already installed.$(RESET)"; \
|
||||
else \
|
||||
echo "$(RED)Poetry 1.8 or later is required. You can install poetry by running the following command, then adding Poetry to your PATH:"; \
|
||||
echo "$(RED) curl -sSL https://install.python-poetry.org | $(PYTHON) -$(RESET)"; \
|
||||
echo "$(RED) curl -sSL https://install.python-poetry.org | python$(PYTHON_VERSION) -$(RESET)"; \
|
||||
echo "$(RED)More detail here: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
else \
|
||||
echo "$(RED)Poetry is not installed. You can install poetry by running the following command, then adding Poetry to your PATH:"; \
|
||||
echo "$(RED) curl -sSL https://install.python-poetry.org | $(PYTHON) -$(RESET)"; \
|
||||
echo "$(RED) curl -sSL https://install.python-poetry.org | python$(PYTHON_VERSION) -$(RESET)"; \
|
||||
echo "$(RED)More detail here: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
install-python-dependencies: check-python
|
||||
install-python-dependencies:
|
||||
@echo "$(GREEN)Installing Python dependencies...$(RESET)"
|
||||
@if [ -z "${TZ}" ]; then \
|
||||
echo "Defaulting TZ (timezone) to UTC"; \
|
||||
export TZ="UTC"; \
|
||||
fi
|
||||
poetry env use $(PYTHON)
|
||||
poetry env use python$(PYTHON_VERSION)
|
||||
@if [ "$(shell uname)" = "Darwin" ]; then \
|
||||
echo "$(BLUE)Installing chroma-hnswlib...$(RESET)"; \
|
||||
export HNSWLIB_NO_NATIVE=1; \
|
||||
|
||||
69
README.md
69
README.md
@@ -23,6 +23,7 @@
|
||||
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=pt">Português</a> |
|
||||
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=ru">Русский</a> |
|
||||
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=zh">中文</a>
|
||||
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
@@ -83,71 +84,3 @@ All our work is available under the MIT license, except for the `enterprise/` di
|
||||
The core `openhands` and `agent-server` Docker images are fully MIT-licensed as well.
|
||||
|
||||
If you need help with anything, or just want to chat, [come find us on Slack](https://dub.sh/openhands).
|
||||
|
||||
<hr>
|
||||
|
||||
### Thank You to Our Contributors
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/OpenHands/OpenHands/graphs/contributors)
|
||||
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
### Trusted by Engineers at
|
||||
|
||||
<div align="center">
|
||||
<br/><br/>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/tiktok.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/tiktok.svg" alt="TikTok" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/vmware.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/vmware.svg" alt="VMware" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/roche.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/roche.svg" alt="Roche" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/amazon.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/amazon.svg" alt="Amazon" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/c3-ai.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/c3-ai.svg" alt="C3 AI" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/netflix.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/netflix.svg" alt="Netflix" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mastercard.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/mastercard.svg" alt="Mastercard" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/red-hat.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/red-hat.svg" alt="Red Hat" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mongodb.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/mongodb.svg" alt="MongoDB" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/apple.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/apple.svg" alt="Apple" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/nvidia.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/nvidia.svg" alt="NVIDIA" height="17" hspace="5">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/google.svg">
|
||||
<img src="https://assets.openhands.dev/logos/external/black/google.svg" alt="Google" height="17" hspace="5">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -296,7 +296,7 @@ classpath = "my_package.my_module.MyCustomAgent"
|
||||
#user_id = 1000
|
||||
|
||||
# Container image to use for the sandbox
|
||||
#base_container_image = "nikolaik/python-nodejs:python3.12-nodejs22-slim"
|
||||
#base_container_image = "nikolaik/python-nodejs:python3.12-nodejs22"
|
||||
|
||||
# Use host network
|
||||
#use_host_network = false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG OPENHANDS_BUILD_VERSION=dev
|
||||
FROM node:25.9-trixie-slim AS frontend-builder
|
||||
FROM node:25.8-trixie-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -20,11 +20,9 @@ ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=1 \
|
||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||
|
||||
# Pin Poetry version to match the version used to generate poetry.lock
|
||||
ARG POETRY_VERSION=2.3.3
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y curl make git build-essential jq gettext \
|
||||
&& python3 -m pip install "poetry==${POETRY_VERSION}" --break-system-packages
|
||||
&& python3 -m pip install "poetry>=2.3.0" --break-system-packages
|
||||
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
RUN touch README.md
|
||||
@@ -52,7 +50,7 @@ RUN mkdir -p $FILE_STORE_PATH
|
||||
RUN mkdir -p $WORKSPACE_BASE
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y curl git ssh sudo \
|
||||
&& apt-get install -y curl ssh sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Default is 1000, but OSX is often 501
|
||||
@@ -75,21 +73,13 @@ ENV VIRTUAL_ENV=/app/.venv \
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
|
||||
# Pin pip to a known-good version (reproducible builds) and fix CVE-2025-8869
|
||||
# Pin both venv pip and system pip (Trivy scans both)
|
||||
# - `python -m pip` uses the venv because `PATH` is prefixed with `${VIRTUAL_ENV}/bin`
|
||||
# - `/usr/local/bin/python3 -m pip` uses the system interpreter regardless of `PATH`
|
||||
ARG PIP_VERSION=26.0.1
|
||||
RUN python -m pip install --no-cache-dir "pip==${PIP_VERSION}"
|
||||
|
||||
USER root
|
||||
RUN /usr/local/bin/python3 -m pip install --no-cache-dir "pip==${PIP_VERSION}" --break-system-packages
|
||||
USER openhands
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
|
||||
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
|
||||
# This is run as "openhands" user, and will create __pycache__ with openhands:openhands ownership
|
||||
RUN python openhands/core/download.py # No-op to download assets
|
||||
# Add this line to set group ownership of all files/directories not already in "app" group
|
||||
# openhands:openhands -> openhands:openhands
|
||||
RUN find /app \! -group openhands -exec chgrp openhands {} +
|
||||
|
||||
4
containers/app/config.sh
Normal file
4
containers/app/config.sh
Normal file
@@ -0,0 +1,4 @@
|
||||
DOCKER_REGISTRY=ghcr.io
|
||||
DOCKER_ORG=openhands
|
||||
DOCKER_IMAGE=openhands
|
||||
DOCKER_BASE_DIR="."
|
||||
@@ -23,6 +23,18 @@ if [ -z "$WORKSPACE_MOUNT_PATH" ]; then
|
||||
unset WORKSPACE_BASE
|
||||
fi
|
||||
|
||||
if [[ "$INSTALL_THIRD_PARTY_RUNTIMES" == "true" ]]; then
|
||||
echo "Downloading and installing third_party_runtimes..."
|
||||
echo "Warning: Third-party runtimes are provided as-is, not actively supported and may be removed in future releases."
|
||||
|
||||
if pip install 'openhands-ai[third_party_runtimes]' -qqq 2> >(tee /dev/stderr); then
|
||||
echo "third_party_runtimes installed successfully."
|
||||
else
|
||||
echo "Failed to install third_party_runtimes." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$SANDBOX_USER_ID" -eq 0 ]]; then
|
||||
echo "Running OpenHands as root"
|
||||
export RUN_AS_OPENHANDS=false
|
||||
|
||||
182
containers/build.sh
Executable file
182
containers/build.sh
Executable file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
# Initialize variables with default values
|
||||
image_name=""
|
||||
org_name=""
|
||||
push=0
|
||||
load=0
|
||||
tag_suffix=""
|
||||
dry_run=0
|
||||
|
||||
# Function to display usage information
|
||||
usage() {
|
||||
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [--dry]"
|
||||
echo " -i: Image name (required)"
|
||||
echo " -o: Organization name"
|
||||
echo " --push: Push the image"
|
||||
echo " --load: Load the image"
|
||||
echo " -t: Tag suffix"
|
||||
echo " --dry: Don't build, only create build-args.json"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse command-line options
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i) image_name="$2"; shift 2 ;;
|
||||
-o) org_name="$2"; shift 2 ;;
|
||||
--push) push=1; shift ;;
|
||||
--load) load=1; shift ;;
|
||||
-t) tag_suffix="$2"; shift 2 ;;
|
||||
--dry) dry_run=1; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
# Check if required arguments are provided
|
||||
if [[ -z "$image_name" ]]; then
|
||||
echo "Error: Image name is required."
|
||||
usage
|
||||
fi
|
||||
|
||||
echo "Building: $image_name"
|
||||
tags=()
|
||||
|
||||
OPENHANDS_BUILD_VERSION="dev"
|
||||
|
||||
cache_tag_base="buildcache"
|
||||
cache_tag="$cache_tag_base"
|
||||
|
||||
if [[ -n $RELEVANT_SHA ]]; then
|
||||
git_hash=$(git rev-parse --short "$RELEVANT_SHA")
|
||||
tags+=("$git_hash")
|
||||
tags+=("$RELEVANT_SHA")
|
||||
fi
|
||||
|
||||
if [[ -n $GITHUB_REF_NAME ]]; then
|
||||
# check if ref name is a version number
|
||||
if [[ $GITHUB_REF_NAME =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
major_version=$(echo "$GITHUB_REF_NAME" | cut -d. -f1)
|
||||
minor_version=$(echo "$GITHUB_REF_NAME" | cut -d. -f1,2)
|
||||
tags+=("$major_version" "$minor_version")
|
||||
tags+=("latest")
|
||||
fi
|
||||
sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
|
||||
OPENHANDS_BUILD_VERSION=$sanitized_ref_name
|
||||
sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging
|
||||
tags+=("$sanitized_ref_name")
|
||||
cache_tag+="-${sanitized_ref_name}"
|
||||
fi
|
||||
|
||||
if [[ -n $tag_suffix ]]; then
|
||||
cache_tag+="-${tag_suffix}"
|
||||
for i in "${!tags[@]}"; do
|
||||
tags[$i]="${tags[$i]}-$tag_suffix"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Tags: ${tags[@]}"
|
||||
|
||||
if [[ "$image_name" == "openhands" ]]; then
|
||||
dir="./containers/app"
|
||||
elif [[ "$image_name" == "runtime" ]]; then
|
||||
dir="./containers/runtime"
|
||||
else
|
||||
dir="./containers/$image_name"
|
||||
fi
|
||||
|
||||
if [[ (! -f "$dir/Dockerfile") && "$image_name" != "runtime" ]]; then
|
||||
# Allow runtime to be built without a Dockerfile
|
||||
echo "No Dockerfile found"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$dir/config.sh" ]]; then
|
||||
echo "No config.sh found for Dockerfile"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$dir/config.sh"
|
||||
|
||||
if [[ -n "$org_name" ]]; then
|
||||
DOCKER_ORG="$org_name"
|
||||
fi
|
||||
|
||||
# If $DOCKER_IMAGE_SOURCE_TAG is set, add it to the tags
|
||||
if [[ -n "$DOCKER_IMAGE_SOURCE_TAG" ]]; then
|
||||
tags+=("$DOCKER_IMAGE_SOURCE_TAG")
|
||||
fi
|
||||
# If $DOCKER_IMAGE_TAG is set, add it to the tags
|
||||
if [[ -n "$DOCKER_IMAGE_TAG" ]]; then
|
||||
tags+=("$DOCKER_IMAGE_TAG")
|
||||
fi
|
||||
|
||||
DOCKER_REPOSITORY="$DOCKER_REGISTRY/$DOCKER_ORG/$DOCKER_IMAGE"
|
||||
DOCKER_REPOSITORY=${DOCKER_REPOSITORY,,} # lowercase
|
||||
echo "Repo: $DOCKER_REPOSITORY"
|
||||
echo "Base dir: $DOCKER_BASE_DIR"
|
||||
|
||||
args=""
|
||||
full_tags=()
|
||||
for tag in "${tags[@]}"; do
|
||||
args+=" -t $DOCKER_REPOSITORY:$tag"
|
||||
full_tags+=("$DOCKER_REPOSITORY:$tag")
|
||||
done
|
||||
|
||||
|
||||
if [[ $push -eq 1 ]]; then
|
||||
args+=" --push"
|
||||
args+=" --cache-to=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag,mode=max"
|
||||
fi
|
||||
|
||||
if [[ $load -eq 1 ]]; then
|
||||
args+=" --load"
|
||||
fi
|
||||
|
||||
echo "Args: $args"
|
||||
|
||||
# Modify the platform selection based on --load flag
|
||||
if [[ $load -eq 1 ]]; then
|
||||
# When loading, build only for the current platform
|
||||
platform=$(docker version -f '{{.Server.Os}}/{{.Server.Arch}}')
|
||||
else
|
||||
# For push or without load, build for multiple platforms
|
||||
platform="linux/amd64,linux/arm64"
|
||||
fi
|
||||
if [[ $dry_run -eq 1 ]]; then
|
||||
echo "Dry Run is enabled. Writing build config to docker-build-dry.json"
|
||||
jq -n \
|
||||
--argjson tags "$(printf '%s\n' "${full_tags[@]}" | jq -R . | jq -s .)" \
|
||||
--arg platform "$platform" \
|
||||
--arg openhands_build_version "$OPENHANDS_BUILD_VERSION" \
|
||||
--arg dockerfile "$dir/Dockerfile" \
|
||||
'{
|
||||
tags: $tags,
|
||||
platform: $platform,
|
||||
build_args: [
|
||||
"OPENHANDS_BUILD_VERSION=" + $openhands_build_version
|
||||
],
|
||||
dockerfile: $dockerfile
|
||||
}' > docker-build-dry.json
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
|
||||
echo "Building for platform(s): $platform"
|
||||
|
||||
docker buildx build \
|
||||
$args \
|
||||
--build-arg OPENHANDS_BUILD_VERSION="$OPENHANDS_BUILD_VERSION" \
|
||||
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag \
|
||||
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag_base-main \
|
||||
--platform $platform \
|
||||
--provenance=false \
|
||||
-f "$dir/Dockerfile" \
|
||||
"$DOCKER_BASE_DIR"
|
||||
|
||||
# If load was requested, print the loaded images
|
||||
if [[ $load -eq 1 ]]; then
|
||||
echo "Local images built:"
|
||||
docker images "$DOCKER_REPOSITORY" --format "{{.Repository}}:{{.Tag}}"
|
||||
fi
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.15.0-python}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
12
containers/runtime/README.md
Normal file
12
containers/runtime/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Dynamically constructed Dockerfile
|
||||
|
||||
This folder builds a runtime image (sandbox), which will use a dynamically generated `Dockerfile`
|
||||
that depends on the `base_image` **AND** a [Python source distribution](https://docs.python.org/3.10/distutils/sourcedist.html) that is based on the current commit of `openhands`.
|
||||
|
||||
The following command will generate a `Dockerfile` file for `nikolaik/python-nodejs:python3.12-nodejs22` (the default base image), an updated `config.sh` and the runtime source distribution files/folders into `containers/runtime`:
|
||||
|
||||
```bash
|
||||
poetry run python3 -m openhands.runtime.utils.runtime_build \
|
||||
--base_image nikolaik/python-nodejs:python3.12-nodejs22 \
|
||||
--build_folder containers/runtime
|
||||
```
|
||||
7
containers/runtime/config.sh
Normal file
7
containers/runtime/config.sh
Normal file
@@ -0,0 +1,7 @@
|
||||
DOCKER_REGISTRY=ghcr.io
|
||||
DOCKER_ORG=openhands
|
||||
DOCKER_BASE_DIR="./containers/runtime"
|
||||
DOCKER_IMAGE=runtime
|
||||
# These variables will be appended by the runtime_build.py script
|
||||
# DOCKER_IMAGE_TAG=
|
||||
# DOCKER_IMAGE_SOURCE_TAG=
|
||||
@@ -3,9 +3,9 @@ repos:
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
- id: check-yaml
|
||||
args: ["--allow-multiple-documents"]
|
||||
- id: debug-statements
|
||||
@@ -37,12 +37,12 @@ repos:
|
||||
entry: ruff check --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
args: [--fix, --unsafe-fixes]
|
||||
exclude: ^(enterprise/)
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
entry: ruff format --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
exclude: ^(enterprise/)
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.15.0
|
||||
@@ -58,9 +58,6 @@ repos:
|
||||
types-Markdown,
|
||||
pydantic,
|
||||
lxml,
|
||||
"openhands-sdk==1.17.0",
|
||||
"openhands-tools==1.17.0",
|
||||
"sqlalchemy>=2.0",
|
||||
]
|
||||
# To see gaps add `--html-report mypy-report/`
|
||||
entry: mypy --config-file dev_config/python/mypy.ini openhands/
|
||||
|
||||
@@ -10,12 +10,7 @@ strict_optional = True
|
||||
disable_error_code = type-abstract
|
||||
|
||||
# Exclude third-party runtime directory from type checking
|
||||
exclude = (enterprise/)
|
||||
exclude = (third_party/|enterprise/)
|
||||
|
||||
[mypy-openai.*]
|
||||
follow_imports = skip
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-litellm.*]
|
||||
follow_imports = skip
|
||||
ignore_missing_imports = True
|
||||
[mypy-openhands.memory.condenser.impl.*]
|
||||
disable_error_code = override
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Exclude third-party runtime directory from linting
|
||||
exclude = ["enterprise/"]
|
||||
exclude = ["third_party/", "enterprise/"]
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.15.0-python}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -33,7 +33,8 @@ RUN cd /tmp/enterprise && \
|
||||
# Export only main dependencies with hashes for supply chain security
|
||||
/app/.venv/bin/poetry export --only main -o requirements.txt && \
|
||||
# Remove the local path dependency (openhands-ai is already in base image)
|
||||
sed -i '/^-e /d; /openhands-ai/d' requirements.txt && \
|
||||
# and git-based SDK dependencies (already installed via the base app image)
|
||||
sed -i '/^-e /d; /openhands-ai/d; /^openhands-.*@ git+/d' requirements.txt && \
|
||||
# Install pinned dependencies from lock file
|
||||
/app/.venv/bin/pip install -r requirements.txt && \
|
||||
# Cleanup - return to /app before removing /tmp/enterprise
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# PolyForm Free Trial License 1.0.0
|
||||
|
||||
Copyright (c) 2026 All Hands AI
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
|
||||
@@ -59,7 +59,7 @@ handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
level = DEBUG
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
|
||||
@@ -723,15 +723,11 @@
|
||||
"https://$WEB_HOST/slack/keycloak-callback",
|
||||
"https://$WEB_HOST/oauth/device/keycloak-callback",
|
||||
"https://$WEB_HOST/api/email/verified",
|
||||
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*",
|
||||
"https://laminar.$WEB_HOST/api/auth/callback/keycloak",
|
||||
"https://analytics.$WEB_HOST/api/auth/callback/keycloak"
|
||||
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"https://$WEB_HOST",
|
||||
"https://$AUTH_WEB_HOST",
|
||||
"https://laminar.$WEB_HOST",
|
||||
"https://analytics.$WEB_HOST"
|
||||
"https://$AUTH_WEB_HOST"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
|
||||
@@ -50,7 +50,6 @@ repos:
|
||||
- ./
|
||||
- stripe==11.5.0
|
||||
- pygithub==2.6.1
|
||||
- sqlalchemy>=2.0
|
||||
# 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
|
||||
|
||||
@@ -61,6 +61,13 @@ export LITE_LLM_API_KEY=<your LLM API key>
|
||||
python enterprise_local/convert_to_env.py
|
||||
```
|
||||
|
||||
You'll also need to set up the runtime image, so that the dev server doesn't try to rebuild it.
|
||||
|
||||
```
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:main-nikolaik
|
||||
docker pull $SANDBOX_RUNTIME_CONTAINER_IMAGE
|
||||
```
|
||||
|
||||
By default the application will log in json, you can override.
|
||||
|
||||
```
|
||||
@@ -196,6 +203,7 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
"GITHUB_APP_ID": "1062351",
|
||||
@@ -229,6 +237,7 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
"GITHUB_APP_ID": "1062351",
|
||||
|
||||
@@ -112,6 +112,9 @@ lines.append(
|
||||
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'
|
||||
)
|
||||
lines.append('POSTHOG_CLIENT_KEY=test')
|
||||
lines.append('ENABLE_PROACTIVE_CONVERSATION_STARTERS=true')
|
||||
lines.append('MAX_CONCURRENT_CONVERSATIONS=10')
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.app_server.integrations.bitbucket.bitbucket_service import (
|
||||
BitBucketService,
|
||||
)
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.bitbucket.bitbucket_service import BitBucketService
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
class SaaSBitBucketService(BitBucketService):
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.app_server.integrations.bitbucket_data_center.bitbucket_dc_service import (
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.bitbucket_data_center.bitbucket_dc_service import (
|
||||
BitbucketDCService,
|
||||
)
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
class SaaSBitbucketDCService(BitbucketDCService):
|
||||
|
||||
@@ -19,12 +19,12 @@ from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
from storage.openhands_pr import OpenhandsPR
|
||||
from storage.openhands_pr_store import OpenhandsPRStore
|
||||
|
||||
from openhands.app_server.conversation_paths import get_conversation_dir
|
||||
from openhands.app_server.file_store import get_file_store
|
||||
from openhands.app_server.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
from openhands.core.config import load_openhands_config
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.locations import get_conversation_dir
|
||||
|
||||
config = load_openhands_config()
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
@@ -112,7 +112,7 @@ class GitHubDataCollector:
|
||||
suffix = path.format(repo_id, number)
|
||||
|
||||
if conversation_id:
|
||||
return f'{get_conversation_dir(conversation_id)}/{suffix}'
|
||||
return f'{get_conversation_dir(conversation_id)}{suffix}'
|
||||
|
||||
return suffix
|
||||
|
||||
@@ -429,11 +429,6 @@ class GitHubDataCollector:
|
||||
- Num openhands review comments
|
||||
"""
|
||||
pr_number = openhands_pr.pr_number
|
||||
if openhands_pr.installation_id is None:
|
||||
logger.warning(
|
||||
f'Skipping PR {openhands_pr.repo_name}#{pr_number}: missing installation_id'
|
||||
)
|
||||
return
|
||||
installation_id = int(openhands_pr.installation_id)
|
||||
repo_id = openhands_pr.repo_id
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from types import MappingProxyType
|
||||
|
||||
from github import Auth, Github, GithubIntegration
|
||||
from integrations.github.data_collector import GitHubDataCollector
|
||||
from integrations.github.github_solvability import summarize_issue_solvability
|
||||
from integrations.github.github_view import (
|
||||
GithubFactory,
|
||||
GithubFailingAction,
|
||||
@@ -19,6 +20,7 @@ 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,
|
||||
@@ -30,16 +32,17 @@ from pydantic import SecretStr
|
||||
from server.auth.auth_error import ExpiredError
|
||||
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.utils.conversation_callback_utils import register_callback_processor
|
||||
|
||||
from openhands.app_server.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.app_server.integrations.service_types import AuthenticationError
|
||||
from openhands.app_server.secrets.secrets_models import Secrets
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.integrations.service_types import AuthenticationError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
class GithubManager(Manager[GithubViewType]):
|
||||
@@ -315,12 +318,17 @@ class GithubManager(Manager[GithubViewType]):
|
||||
return
|
||||
|
||||
async def start_job(self, github_view: GithubViewType) -> None:
|
||||
"""Kick off a job with openhands agent using V1 app conversation system.
|
||||
"""Kick off a job with openhands agent.
|
||||
|
||||
1. Get user credential
|
||||
2. Initialize new conversation with repo
|
||||
3. Save interaction data
|
||||
"""
|
||||
# Importing here prevents circular import
|
||||
from server.conversation_callback_processor.github_callback_processor import (
|
||||
GithubCallbackProcessor,
|
||||
)
|
||||
|
||||
try:
|
||||
msg_info: str = ''
|
||||
|
||||
@@ -356,7 +364,26 @@ class GithubManager(Manager[GithubViewType]):
|
||||
)
|
||||
)
|
||||
|
||||
conversation_id = await github_view.initialize_new_conversation()
|
||||
# We first initialize a conversation and generate the solvability report BEFORE starting the conversation runtime
|
||||
# This helps us accumulate llm spend without requiring a running runtime. This setups us up for
|
||||
# 1. If there is a problem starting the runtime we still have accumulated total conversation cost
|
||||
# 2. In the future, based on the report confidence we can conditionally start the conversation
|
||||
# 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:
|
||||
solvability_summary = await summarize_issue_solvability(
|
||||
github_view, user_token
|
||||
)
|
||||
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
|
||||
@@ -365,21 +392,38 @@ class GithubManager(Manager[GithubViewType]):
|
||||
await github_view.create_new_conversation(
|
||||
self.jinja_env,
|
||||
secret_store.provider_tokens,
|
||||
conversation_id,
|
||||
convo_metadata,
|
||||
saas_user_auth,
|
||||
)
|
||||
|
||||
conversation_id_hex = github_view.conversation_id
|
||||
conversation_id = github_view.conversation_id
|
||||
|
||||
logger.info(
|
||||
f'[GitHub] Created conversation {conversation_id_hex} for user {user_info.username}'
|
||||
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
|
||||
)
|
||||
|
||||
# V1 callback processors are registered by the view during conversation creation
|
||||
if not github_view.v1_enabled:
|
||||
# Create a GithubCallbackProcessor
|
||||
processor = GithubCallbackProcessor(
|
||||
github_view=github_view,
|
||||
send_summary_instruction=True,
|
||||
)
|
||||
|
||||
# Register the callback processor
|
||||
register_callback_processor(conversation_id, processor)
|
||||
|
||||
logger.info(
|
||||
f'[Github] Registered callback processor for conversation {conversation_id}'
|
||||
)
|
||||
|
||||
# Send message with conversation link
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id_hex)
|
||||
msg_info = f"I'm on it! {user_info.username} can [track my progress at all-hands.dev]({conversation_link})"
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
base_msg = f"I'm on it! {user_info.username} can [track my progress at all-hands.dev]({conversation_link})"
|
||||
# Combine messages: include solvability report with "I'm on it!" if successful
|
||||
if solvability_summary:
|
||||
msg_info = f'{base_msg}\n\n{solvability_summary}'
|
||||
else:
|
||||
msg_info = base_msg
|
||||
|
||||
except MissingSettingsError as e:
|
||||
logger.warning(
|
||||
|
||||
@@ -4,9 +4,9 @@ from integrations.store_repo_utils import store_repositories_in_db
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.app_server.integrations.github.github_service import GitHubService
|
||||
from openhands.app_server.integrations.service_types import ProviderType, Repository
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.github_service import GitHubService
|
||||
from openhands.integrations.service_types import ProviderType, Repository
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
|
||||
186
enterprise/integrations/github/github_solvability.py
Normal file
186
enterprise/integrations/github/github_solvability.py
Normal file
@@ -0,0 +1,186 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from github import Auth, Github
|
||||
from integrations.github.github_view import (
|
||||
GithubInlinePRComment,
|
||||
GithubIssueComment,
|
||||
GithubPRComment,
|
||||
GithubViewType,
|
||||
)
|
||||
from integrations.solvability.data import load_classifier
|
||||
from integrations.solvability.models.report import SolvabilityReport
|
||||
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.saas_settings_store import SaasSettingsStore
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.utils import create_registry_and_conversation_stats
|
||||
|
||||
|
||||
def fetch_github_issue_context(
|
||||
github_view: GithubViewType,
|
||||
user_token: str,
|
||||
) -> str:
|
||||
"""Fetch full GitHub issue/PR context including title, body, and comments.
|
||||
|
||||
Args:
|
||||
full_repo_name: Full repository name in the format 'owner/repo'
|
||||
issue_number: The issue or PR number
|
||||
user_token: GitHub user access token
|
||||
max_comments: Maximum number of comments to fetch (default: 10)
|
||||
max_comment_length: Maximum length of each comment to include in the context (default: 500)
|
||||
|
||||
Returns:
|
||||
A comprehensive string containing the issue/PR context
|
||||
"""
|
||||
|
||||
# Build context string
|
||||
context_parts = []
|
||||
|
||||
# Add title and body
|
||||
context_parts.append(f'Title: {github_view.title}')
|
||||
context_parts.append(f'Description:\n{github_view.description}')
|
||||
|
||||
with Github(auth=Auth.Token(user_token)) as github_client:
|
||||
repo = github_client.get_repo(github_view.full_repo_name)
|
||||
issue = repo.get_issue(github_view.issue_number)
|
||||
if issue.labels:
|
||||
labels = [label.name for label in issue.labels]
|
||||
context_parts.append(f"Labels: {', '.join(labels)}")
|
||||
|
||||
for comment in github_view.previous_comments:
|
||||
context_parts.append(f'- {comment.author}: {comment.body}')
|
||||
|
||||
return '\n\n'.join(context_parts)
|
||||
|
||||
|
||||
async def summarize_issue_solvability(
|
||||
github_view: GithubViewType,
|
||||
user_token: str,
|
||||
timeout: float = 60.0 * 5,
|
||||
) -> str:
|
||||
"""Generate a solvability summary for an issue using the resolver view interface.
|
||||
|
||||
Args:
|
||||
resolver_view: A resolver view interface instance (e.g., GithubIssue, GithubPRComment)
|
||||
user_token: GitHub user access token for API access
|
||||
timeout: Maximum time in seconds to wait for the result (default: 60.0)
|
||||
|
||||
Returns:
|
||||
The solvability summary as a string
|
||||
|
||||
Raises:
|
||||
ValueError: If LLM settings cannot be found for the user
|
||||
asyncio.TimeoutError: If the operation exceeds the specified timeout
|
||||
"""
|
||||
if not ENABLE_SOLVABILITY_ANALYSIS:
|
||||
raise ValueError('Solvability report feature is disabled')
|
||||
|
||||
if github_view.user_info.keycloak_user_id is None:
|
||||
raise ValueError(
|
||||
f'[Solvability] No user ID found for user {github_view.user_info.username}'
|
||||
)
|
||||
|
||||
# Grab the user's information so we can load their LLM configuration
|
||||
store = SaasSettingsStore(
|
||||
user_id=github_view.user_info.keycloak_user_id,
|
||||
config=get_config(),
|
||||
)
|
||||
|
||||
user_settings = await store.load()
|
||||
|
||||
if user_settings is None:
|
||||
raise ValueError(
|
||||
f'[Solvability] No user settings found for user ID {github_view.user_info.user_id}'
|
||||
)
|
||||
|
||||
# Check if solvability analysis is enabled for this user, exit early if
|
||||
# needed
|
||||
if not getattr(user_settings, 'enable_solvability_analysis', False):
|
||||
raise ValueError(
|
||||
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,
|
||||
api_key=user_settings.llm_api_key.get_secret_value(),
|
||||
base_url=user_settings.llm_base_url,
|
||||
)
|
||||
except ValidationError as e:
|
||||
raise ValueError(
|
||||
f'[Solvability] Invalid LLM configuration for user {github_view.user_info.user_id}: {str(e)}'
|
||||
)
|
||||
|
||||
# Fetch the full GitHub issue/PR context using the GitHub API
|
||||
start_time = time.time()
|
||||
issue_context = fetch_github_issue_context(github_view, user_token)
|
||||
logger.info(
|
||||
f'[Solvability] Grabbed issue context for {github_view.conversation_id}',
|
||||
extra={
|
||||
'conversation_id': github_view.conversation_id,
|
||||
'response_latency': time.time() - start_time,
|
||||
'full_repo_name': github_view.full_repo_name,
|
||||
'issue_number': github_view.issue_number,
|
||||
},
|
||||
)
|
||||
|
||||
# For comment-based triggers, also include the specific comment that triggered the action
|
||||
if isinstance(
|
||||
github_view, (GithubIssueComment, GithubPRComment, GithubInlinePRComment)
|
||||
):
|
||||
issue_context += f'\n\nTriggering Comment:\n{github_view.comment_body}'
|
||||
|
||||
solvability_classifier = load_classifier('default-classifier')
|
||||
|
||||
async with asyncio.timeout(timeout):
|
||||
solvability_report: SolvabilityReport = await call_sync_from_async(
|
||||
lambda: solvability_classifier.solvability_report(
|
||||
issue_context, llm_config=llm_config
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Solvability] Generated report for {github_view.conversation_id}',
|
||||
extra={
|
||||
'conversation_id': github_view.conversation_id,
|
||||
'report': solvability_report.model_dump(exclude=['issue']),
|
||||
},
|
||||
)
|
||||
|
||||
llm_registry, conversation_stats, _ = create_registry_and_conversation_stats(
|
||||
get_config(),
|
||||
github_view.conversation_id,
|
||||
github_view.user_info.keycloak_user_id,
|
||||
None,
|
||||
)
|
||||
|
||||
solvability_summary = await call_sync_from_async(
|
||||
lambda: SolvabilitySummary.from_report(
|
||||
solvability_report,
|
||||
llm=llm_registry.get_llm(
|
||||
service_id='solvability_analysis', config=llm_config
|
||||
),
|
||||
)
|
||||
)
|
||||
conversation_stats.save_metrics()
|
||||
|
||||
logger.info(
|
||||
f'[Solvability] Generated summary for {github_view.conversation_id}',
|
||||
extra={
|
||||
'conversation_id': github_view.conversation_id,
|
||||
'summary': solvability_summary.model_dump(exclude=['content']),
|
||||
},
|
||||
)
|
||||
|
||||
return solvability_summary.format_as_markdown()
|
||||
@@ -43,20 +43,15 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for GitHub V1 integration."""
|
||||
# Only handle ConversationStateUpdateEvent for execution_status
|
||||
# Only handle ConversationStateUpdateEvent
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
|
||||
if event.key != 'execution_status':
|
||||
# Only act when execution has finished
|
||||
if not (event.key == 'execution_status' and event.value == 'finished'):
|
||||
return None
|
||||
|
||||
# Log ALL terminal states for monitoring (finished, error, stuck)
|
||||
_logger.info('[GitHub V1] Callback agent state was %s', event)
|
||||
|
||||
# Only request summary when execution has finished successfully
|
||||
if event.value != 'finished':
|
||||
return None
|
||||
|
||||
_logger.info(
|
||||
'[GitHub V1] Should request summary: %s', self.should_request_summary
|
||||
)
|
||||
|
||||
@@ -10,13 +10,14 @@ from integrations.github.github_types import (
|
||||
)
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
ENABLE_V1_GITHUB_RESOLVER,
|
||||
HOST,
|
||||
HOST_URL,
|
||||
get_oh_labels,
|
||||
get_user_v1_enabled_setting,
|
||||
has_exact_mention,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
@@ -31,22 +32,33 @@ from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.app_server.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.app_server.integrations.service_types import Comment
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import Comment
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.services.conversation_service import (
|
||||
initialize_conversation,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
|
||||
async def is_v1_enabled_for_github_resolver(user_id: str) -> bool:
|
||||
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_GITHUB_RESOLVER
|
||||
|
||||
|
||||
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
"""Get the user's proactive conversation setting.
|
||||
|
||||
@@ -94,6 +106,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
title: str
|
||||
description: str
|
||||
previous_comments: list[Comment]
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_branch_name(self) -> str | None:
|
||||
return getattr(self, 'branch_name', None)
|
||||
@@ -140,28 +153,83 @@ class GithubIssue(ResolverViewInterface):
|
||||
|
||||
return user_secrets.custom_secrets if user_secrets else None
|
||||
|
||||
async def initialize_new_conversation(self) -> UUID:
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider='github',
|
||||
full_repo_name=self.full_repo_name,
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# FIXME: Handle if initialize_conversation returns None
|
||||
|
||||
self.v1_enabled = await is_v1_enabled_for_github_resolver(
|
||||
self.user_info.keycloak_user_id
|
||||
)
|
||||
|
||||
# All conversations use V1 app conversation service
|
||||
conversation_id = uuid4()
|
||||
self.conversation_id = conversation_id.hex
|
||||
return conversation_id
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
|
||||
)
|
||||
if self.v1_enabled:
|
||||
# Create dummy conversationm 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.GITHUB,
|
||||
)
|
||||
|
||||
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_id: UUID,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
# V0 conversation path has been removed - all conversations use V1 app conversation service
|
||||
await self._create_v1_conversation(jinja_env, saas_user_auth, conversation_id)
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
|
||||
)
|
||||
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('[GitHub]: Creating V0 conversation')
|
||||
custom_secrets = await self._get_user_secrets()
|
||||
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja_env
|
||||
)
|
||||
|
||||
await start_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 _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
"""Build the initial user message for V1 resolver conversations.
|
||||
@@ -173,6 +241,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
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
|
||||
)
|
||||
@@ -189,7 +258,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
saas_user_auth: UserAuth,
|
||||
conversation_id: UUID,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[GitHub V1]: Creating V1 conversation')
|
||||
@@ -209,7 +278,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
|
||||
# Create the V1 conversation start request with the callback processor
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=conversation_id,
|
||||
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,
|
||||
@@ -225,10 +294,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
)
|
||||
|
||||
# Set up the GitHub user context for the V1 system
|
||||
github_user_context = ResolverUserContext(
|
||||
saas_user_auth=saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
@@ -256,7 +322,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
'full_repo_name': self.full_repo_name,
|
||||
'installation_id': self.installation_id,
|
||||
},
|
||||
should_request_summary=self.send_summary_instruction,
|
||||
send_summary_instruction=self.send_summary_instruction,
|
||||
)
|
||||
|
||||
|
||||
@@ -410,7 +476,7 @@ class GithubInlinePRComment(GithubPRComment):
|
||||
'comment_id': self.comment_id,
|
||||
},
|
||||
inline_pr_comment=True,
|
||||
should_request_summary=self.send_summary_instruction,
|
||||
send_summary_instruction=self.send_summary_instruction,
|
||||
)
|
||||
|
||||
|
||||
@@ -763,6 +829,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_issue_comment(message):
|
||||
@@ -788,6 +855,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_pr_comment(message):
|
||||
@@ -829,6 +897,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_inline_pr_comment(message):
|
||||
@@ -862,6 +931,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -24,16 +24,17 @@ 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
|
||||
from server.utils.conversation_callback_utils import register_callback_processor
|
||||
|
||||
from openhands.app_server.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.app_server.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.app_server.secrets.secrets_models import Secrets
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
class GitlabManager(Manager[GitlabViewType]):
|
||||
@@ -170,11 +171,17 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
)
|
||||
|
||||
async def start_job(self, gitlab_view: GitlabViewType) -> None:
|
||||
"""Start a job for the GitLab view using V1 app conversation system.
|
||||
"""
|
||||
Start a job for the GitLab view.
|
||||
|
||||
Args:
|
||||
gitlab_view: The GitLab view object containing issue/PR/comment info
|
||||
"""
|
||||
# Importing here prevents circular import
|
||||
from server.conversation_callback_processor.gitlab_callback_processor import (
|
||||
GitlabCallbackProcessor,
|
||||
)
|
||||
|
||||
try:
|
||||
try:
|
||||
user_info = gitlab_view.user_info
|
||||
@@ -208,8 +215,8 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
)
|
||||
)
|
||||
|
||||
# Initialize conversation and get UUID
|
||||
conversation_id = await gitlab_view.initialize_new_conversation()
|
||||
# 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
|
||||
@@ -218,19 +225,31 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
await gitlab_view.create_new_conversation(
|
||||
self.jinja_env,
|
||||
secret_store.provider_tokens,
|
||||
conversation_id,
|
||||
convo_metadata,
|
||||
saas_user_auth,
|
||||
)
|
||||
|
||||
conversation_id_hex = gitlab_view.conversation_id
|
||||
conversation_id = gitlab_view.conversation_id
|
||||
|
||||
logger.info(
|
||||
f'[GitLab] Created conversation {conversation_id_hex} for user {user_info.username}'
|
||||
f'[GitLab] Created conversation {conversation_id} for user {user_info.username}'
|
||||
)
|
||||
|
||||
# V1 callback processors are registered by the view during conversation creation
|
||||
if not gitlab_view.v1_enabled:
|
||||
# Create a GitlabCallbackProcessor for this conversation
|
||||
processor = GitlabCallbackProcessor(
|
||||
gitlab_view=gitlab_view,
|
||||
send_summary_instruction=True,
|
||||
)
|
||||
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id_hex)
|
||||
# Register the callback processor
|
||||
register_callback_processor(conversation_id, processor)
|
||||
|
||||
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})"
|
||||
|
||||
except MissingSettingsError as e:
|
||||
|
||||
@@ -7,14 +7,14 @@ from server.auth.token_manager import TokenManager
|
||||
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.app_server.integrations.gitlab.gitlab_service import GitLabService
|
||||
from openhands.app_server.integrations.service_types import (
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabService
|
||||
from openhands.integrations.service_types import (
|
||||
ProviderType,
|
||||
RateLimitError,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
|
||||
@@ -41,20 +41,15 @@ class GitlabV1CallbackProcessor(EventCallbackProcessor):
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for GitLab V1 integration."""
|
||||
# Only handle ConversationStateUpdateEvent for execution_status
|
||||
# Only handle ConversationStateUpdateEvent
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
|
||||
if event.key != 'execution_status':
|
||||
# Only act when execution has finished
|
||||
if not (event.key == 'execution_status' and event.value == 'finished'):
|
||||
return None
|
||||
|
||||
# Log ALL terminal states for monitoring (finished, error, stuck)
|
||||
_logger.info('[GitLab V1] Callback agent state was %s', event)
|
||||
|
||||
# Only request summary when execution has finished successfully
|
||||
if event.value != 'finished':
|
||||
return None
|
||||
|
||||
_logger.info(
|
||||
'[GitLab V1] Should request summary: %s', self.should_request_summary
|
||||
)
|
||||
|
||||
@@ -3,11 +3,12 @@ from uuid import UUID, uuid4
|
||||
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_V1_GITLAB_RESOLVER,
|
||||
HOST,
|
||||
get_oh_labels,
|
||||
get_user_v1_enabled_setting,
|
||||
has_exact_mention,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
@@ -19,23 +20,34 @@ from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.app_server.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.app_server.integrations.service_types import Comment
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
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,
|
||||
)
|
||||
|
||||
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
|
||||
# =================================================
|
||||
@@ -57,6 +69,7 @@ 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)
|
||||
@@ -102,34 +115,80 @@ class GitlabIssue(ResolverViewInterface):
|
||||
|
||||
return user_secrets.custom_secrets if user_secrets else None
|
||||
|
||||
async def initialize_new_conversation(self) -> UUID:
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider='gitlab',
|
||||
full_repo_name=self.full_repo_name,
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
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,
|
||||
)
|
||||
|
||||
# All conversations use V1 app conversation service
|
||||
conversation_id = uuid4()
|
||||
self.conversation_id = conversation_id.hex
|
||||
return conversation_id
|
||||
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_id: UUID,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
# V0 conversation path has been removed - all conversations use V1 app conversation service
|
||||
await self._create_v1_conversation(jinja_env, saas_user_auth, conversation_id)
|
||||
# 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(
|
||||
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_id: UUID,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[GitLab V1]: Creating V1 conversation')
|
||||
@@ -155,7 +214,7 @@ class GitlabIssue(ResolverViewInterface):
|
||||
|
||||
# Create the V1 conversation start request with the callback processor
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=conversation_id,
|
||||
conversation_id=UUID(conversation_metadata.conversation_id),
|
||||
system_message_suffix=conversation_instructions,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.full_repo_name,
|
||||
@@ -169,10 +228,7 @@ class GitlabIssue(ResolverViewInterface):
|
||||
)
|
||||
|
||||
# Set up the GitLab user context for the V1 system
|
||||
gitlab_user_context = ResolverUserContext(
|
||||
saas_user_auth=saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
gitlab_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, gitlab_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
@@ -204,7 +260,7 @@ class GitlabIssue(ResolverViewInterface):
|
||||
'is_mr': self.is_mr,
|
||||
'discussion_id': getattr(self, 'discussion_id', None),
|
||||
},
|
||||
should_request_summary=self.send_summary_instruction,
|
||||
send_summary_instruction=self.send_summary_instruction,
|
||||
)
|
||||
|
||||
|
||||
@@ -404,6 +460,16 @@ 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']
|
||||
|
||||
@@ -425,6 +491,7 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=False,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
elif GitlabFactory.is_issue_comment(message):
|
||||
@@ -455,6 +522,7 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=False,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
elif GitlabFactory.is_mr_comment(message):
|
||||
@@ -487,6 +555,7 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=True,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
elif GitlabFactory.is_mr_comment(message, inline=True):
|
||||
@@ -527,6 +596,7 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=True,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
raise ValueError(f'Unhandled GitLab webhook event: {message}')
|
||||
|
||||
@@ -24,31 +24,31 @@ from integrations.jira.jira_types import (
|
||||
RepositoryNotFoundError,
|
||||
StartingConvoException,
|
||||
)
|
||||
from integrations.jira.jira_view import JiraFactory
|
||||
from integrations.jira.jira_view import JiraFactory, JiraNewConversationView
|
||||
from integrations.manager import Manager
|
||||
from integrations.models import Message
|
||||
from integrations.utils import (
|
||||
HOST,
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
format_jira_comment_body,
|
||||
get_oh_labels,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.utils.conversation_callback_utils import register_callback_processor
|
||||
from storage.jira_integration_store import JiraIntegrationStore
|
||||
from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
@@ -259,6 +259,11 @@ class JiraManager(Manager[JiraViewInterface]):
|
||||
|
||||
async def start_job(self, view: JiraViewInterface) -> None:
|
||||
"""Start a Jira job/conversation."""
|
||||
# Import here to prevent circular import
|
||||
from server.conversation_callback_processor.jira_callback_processor import (
|
||||
JiraCallbackProcessor,
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
'[Jira] Starting job',
|
||||
@@ -280,7 +285,19 @@ class JiraManager(Manager[JiraViewInterface]):
|
||||
},
|
||||
)
|
||||
|
||||
# Create success message
|
||||
# Register callback processor for updates
|
||||
if isinstance(view, JiraNewConversationView):
|
||||
processor = JiraCallbackProcessor(
|
||||
issue_key=view.payload.issue_key,
|
||||
workspace_name=view.jira_workspace.name,
|
||||
)
|
||||
register_callback_processor(conversation_id, processor)
|
||||
logger.info(
|
||||
'[Jira] Callback processor registered',
|
||||
extra={'conversation_id': conversation_id},
|
||||
)
|
||||
|
||||
# Send success response
|
||||
msg_info = view.get_response_msg()
|
||||
|
||||
except MissingSettingsError as e:
|
||||
@@ -342,7 +359,7 @@ class JiraManager(Manager[JiraViewInterface]):
|
||||
url = (
|
||||
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
|
||||
)
|
||||
data = format_jira_comment_body(message)
|
||||
data = {'body': 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
|
||||
|
||||
@@ -136,10 +136,11 @@ class JiraPayloadParser:
|
||||
items = changelog.get('items', [])
|
||||
|
||||
# Extract labels that were added
|
||||
labels = set()
|
||||
for item in items:
|
||||
if item.get('field') == 'labels' and item.get('toString'):
|
||||
labels.update(item['toString'].split())
|
||||
labels = [
|
||||
item.get('toString', '')
|
||||
for item in items
|
||||
if item.get('field') == 'labels' and 'toString' in item
|
||||
]
|
||||
|
||||
if self.oh_label not in labels:
|
||||
return JiraPayloadSkipped(
|
||||
|
||||
@@ -7,7 +7,7 @@ from jinja2 import Environment
|
||||
from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from integrations.jira.jira_payload import JiraWebhookPayload
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from integrations.utils import format_jira_comment_body, get_summary_instruction
|
||||
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
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
|
||||
|
||||
class JiraV1CallbackProcessor(EventCallbackProcessor):
|
||||
"""Callback processor for Jira V1 integrations."""
|
||||
|
||||
should_request_summary: bool = Field(default=True)
|
||||
svc_acc_email: str
|
||||
decrypted_api_key: str
|
||||
issue_key: str
|
||||
jira_cloud_id: str
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
callback: EventCallback,
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for Jira V1 integration."""
|
||||
# Only handle ConversationStateUpdateEvent for execution_status
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
|
||||
if event.key != 'execution_status':
|
||||
return None
|
||||
|
||||
_logger.info('[Jira] Callback agent state was %s', event)
|
||||
|
||||
# Only request summary when execution has finished successfully
|
||||
if event.value != 'finished':
|
||||
return None
|
||||
|
||||
_logger.info('[Jira] 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'[Jira] Requesting summary {conversation_id}')
|
||||
summary = await self._request_summary(conversation_id)
|
||||
_logger.info(
|
||||
f'[Jira] Posting summary {conversation_id}',
|
||||
extra={'summary': summary},
|
||||
)
|
||||
await self._post_summary_to_jira(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:
|
||||
_logger.exception(f'[Jira] Failed to post summary: {e}', stack_info=True)
|
||||
return EventCallbackResult(
|
||||
status=EventCallbackResultStatus.ERROR,
|
||||
event_callback_id=callback.id,
|
||||
event_id=event.id,
|
||||
conversation_id=conversation_id,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
async def _request_summary(self, conversation_id: UUID) -> str:
|
||||
"""Ask the agent to produce a summary of its work and return the agent response."""
|
||||
# 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,
|
||||
)
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
_logger.exception(
|
||||
'[Jira] HTTP error sending message to %s: %s. '
|
||||
'Request payload: %s. Response headers: %s',
|
||||
url,
|
||||
error_detail,
|
||||
payload,
|
||||
dict(e.response.headers),
|
||||
stack_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.exception(
|
||||
'[Jira] Timeout error: %s. Request payload: %s',
|
||||
error_detail,
|
||||
payload,
|
||||
stack_info=True,
|
||||
)
|
||||
raise Exception(f'Failed to send message to agent server: {error_detail}')
|
||||
|
||||
async def _post_summary_to_jira(self, summary: str):
|
||||
"""Post the summary back to the Jira issue."""
|
||||
if not all(
|
||||
[
|
||||
self.svc_acc_email,
|
||||
self.decrypted_api_key,
|
||||
self.issue_key,
|
||||
self.jira_cloud_id,
|
||||
]
|
||||
):
|
||||
_logger.warning('[Jira] Missing required data for posting summary')
|
||||
return
|
||||
|
||||
# Add a comment to the Jira issue with the summary
|
||||
comment_url = (
|
||||
f'{JIRA_CLOUD_API_URL}/{self.jira_cloud_id}'
|
||||
f'/rest/api/2/issue/{self.issue_key}/comment'
|
||||
)
|
||||
|
||||
message = f'OpenHands resolved this issue:\n\n{summary}'
|
||||
comment_body = format_jira_comment_body(message)
|
||||
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.post(
|
||||
comment_url,
|
||||
auth=(self.svc_acc_email, self.decrypted_api_key),
|
||||
json=comment_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
_logger.info(f'[Jira] Posted summary to {self.issue_key}')
|
||||
@@ -7,7 +7,6 @@ Views are responsible for:
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
from integrations.jira.jira_payload import JiraWebhookPayload
|
||||
@@ -16,34 +15,18 @@ from integrations.jira.jira_types import (
|
||||
RepositoryNotFoundError,
|
||||
StartingConvoException,
|
||||
)
|
||||
from integrations.jira.jira_v1_callback_processor import (
|
||||
JiraV1CallbackProcessor,
|
||||
)
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
infer_repo_from_message,
|
||||
)
|
||||
from integrations.utils import CONVERSATION_URL, infer_repo_from_message
|
||||
from jinja2 import Environment
|
||||
from storage.jira_conversation import JiraConversation
|
||||
from storage.jira_integration_store import JiraIntegrationStore
|
||||
from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.server.services.conversation_service import create_new_conversation
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
@@ -63,7 +46,7 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
saas_user_auth: UserAuth
|
||||
jira_user: JiraUser
|
||||
jira_workspace: JiraWorkspace
|
||||
selected_repo: str = ''
|
||||
selected_repo: str | None = None
|
||||
conversation_id: str = ''
|
||||
|
||||
# Lazy-loaded issue details (cached after first fetch)
|
||||
@@ -73,9 +56,6 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
# Decrypted API key (set by factory)
|
||||
_decrypted_api_key: str = field(default='', repr=False)
|
||||
|
||||
# Resolved org ID for V1 conversations
|
||||
resolved_org_id: UUID | None = None
|
||||
|
||||
async def get_issue_details(self) -> tuple[str, str]:
|
||||
"""Fetch issue details from Jira API (cached after first call).
|
||||
|
||||
@@ -181,129 +161,56 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
if not self.selected_repo:
|
||||
raise StartingConvoException('No repository selected for this conversation')
|
||||
|
||||
jira_conversation = JiraConversation(
|
||||
conversation_id=self.conversation_id,
|
||||
issue_id=self.payload.issue_id,
|
||||
issue_key=self.payload.issue_key,
|
||||
jira_user_id=self.jira_user.id,
|
||||
)
|
||||
await integration_store.create_conversation(jira_conversation)
|
||||
|
||||
conversation_id = await self._initialize_conversation()
|
||||
await self._create_v1_conversation(jinja_env, conversation_id)
|
||||
return self.conversation_id
|
||||
|
||||
async def _initialize_conversation(self) -> UUID:
|
||||
"""Initialize conversation and return the conversation ID.
|
||||
|
||||
The JiraConversation mapping is saved to the integration store (above), but
|
||||
V1 conversation metadata is managed by the app conversation system, not
|
||||
the legacy conversation store.
|
||||
"""
|
||||
logger.info('[Jira]: Initializing V1 conversation')
|
||||
|
||||
# Generate a conversation ID for V1
|
||||
conversation_id = uuid4()
|
||||
self.conversation_id = conversation_id.hex
|
||||
self.resolved_org_id = await self._get_resolved_org_id()
|
||||
|
||||
return conversation_id
|
||||
|
||||
async def _create_v1_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
conversation_id: UUID,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[Jira]: Creating V1 conversation')
|
||||
|
||||
initial_user_text = await self._get_v1_initial_user_message(jinja_env)
|
||||
|
||||
# Create the initial message request
|
||||
initial_message = SendMessageRequest(
|
||||
role='user', content=[TextContent(text=initial_user_text)]
|
||||
)
|
||||
|
||||
# Create the Jira V1 callback processor
|
||||
jira_callback_processor = self._create_jira_v1_callback_processor()
|
||||
|
||||
injector_state = InjectorState()
|
||||
|
||||
# Create the V1 conversation start request
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=conversation_id,
|
||||
system_message_suffix=None,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
git_provider=ProviderType.GITHUB,
|
||||
title=f'Jira Issue {self.payload.issue_key}: {self._issue_title or "Unknown"}',
|
||||
trigger=ConversationTrigger.JIRA,
|
||||
processors=[jira_callback_processor],
|
||||
)
|
||||
|
||||
# Set up the Jira user context for the V1 system
|
||||
jira_user_context = ResolverUserContext(
|
||||
saas_user_auth=self.saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, jira_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}'
|
||||
)
|
||||
|
||||
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
"""Build the initial user message for V1 resolver conversations."""
|
||||
issue_title, issue_description = await self.get_issue_details()
|
||||
|
||||
user_msg_template = jinja_env.get_template('jira_new_conversation.j2')
|
||||
user_msg = user_msg_template.render(
|
||||
issue_key=self.payload.issue_key,
|
||||
issue_title=issue_title,
|
||||
issue_description=issue_description,
|
||||
user_message=self.payload.user_msg,
|
||||
)
|
||||
|
||||
return user_msg
|
||||
|
||||
def _create_jira_v1_callback_processor(self):
|
||||
"""Create a V1 callback processor for Jira integration."""
|
||||
return JiraV1CallbackProcessor(
|
||||
svc_acc_email=self.jira_workspace.svc_acc_email,
|
||||
decrypted_api_key=self._decrypted_api_key,
|
||||
issue_key=self.payload.issue_key,
|
||||
jira_cloud_id=self.jira_workspace.jira_cloud_id,
|
||||
)
|
||||
|
||||
async def _get_resolved_org_id(self) -> UUID | None:
|
||||
"""Resolve the org ID for V1 conversations."""
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if not provider_tokens:
|
||||
return None
|
||||
user_secrets = await self.saas_user_auth.get_secrets()
|
||||
instructions, user_msg = await self._get_instructions(jinja_env)
|
||||
|
||||
try:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
resolved_org_id = await resolve_org_for_repo(
|
||||
provider=repository.git_provider.value,
|
||||
full_repo_name=self.selected_repo,
|
||||
keycloak_user_id=self.jira_user.keycloak_user_id,
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=self.jira_user.keycloak_user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
initial_user_msg=user_msg,
|
||||
conversation_instructions=instructions,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_trigger=ConversationTrigger.JIRA,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
)
|
||||
return resolved_org_id
|
||||
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
|
||||
logger.info(
|
||||
'[Jira] Created conversation',
|
||||
extra={
|
||||
'conversation_id': self.conversation_id,
|
||||
'issue_key': self.payload.issue_key,
|
||||
'selected_repo': self.selected_repo,
|
||||
},
|
||||
)
|
||||
|
||||
# Store Jira conversation mapping
|
||||
jira_conversation = JiraConversation(
|
||||
conversation_id=self.conversation_id,
|
||||
issue_id=self.payload.issue_id,
|
||||
issue_key=self.payload.issue_key,
|
||||
jira_user_id=self.jira_user.id,
|
||||
)
|
||||
|
||||
await integration_store.create_conversation(jira_conversation)
|
||||
|
||||
return self.conversation_id
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Jira] Failed to resolve org for {self.selected_repo}: {e}'
|
||||
if isinstance(e, StartingConvoException):
|
||||
raise
|
||||
logger.error(
|
||||
'[Jira] Failed to create conversation',
|
||||
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
"""Get the response message to send back to Jira."""
|
||||
|
||||
@@ -20,25 +20,25 @@ from integrations.utils import (
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_session_expired_message,
|
||||
markdown_to_jira_markup,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.utils.conversation_callback_utils import register_callback_processor
|
||||
from storage.jira_dc_integration_store import JiraDcIntegrationStore
|
||||
from storage.jira_dc_user import JiraDcUser
|
||||
from storage.jira_dc_workspace import JiraDcWorkspace
|
||||
|
||||
from openhands.app_server.integrations.provider import ProviderHandler
|
||||
from openhands.app_server.integrations.service_types import Repository
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
|
||||
@@ -354,7 +354,12 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
return False
|
||||
|
||||
async def start_job(self, jira_dc_view: JiraDcViewInterface) -> None:
|
||||
"""Start a Jira DC job/conversation using V1 app conversation system."""
|
||||
"""Start a Jira DC job/conversation."""
|
||||
# Import here to prevent circular import
|
||||
from server.conversation_callback_processor.jira_dc_callback_processor import (
|
||||
JiraDcCallbackProcessor,
|
||||
)
|
||||
|
||||
try:
|
||||
user_info: JiraDcUser = jira_dc_view.jira_dc_user
|
||||
logger.info(
|
||||
@@ -362,15 +367,7 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
f'issue {jira_dc_view.job_context.issue_key}',
|
||||
)
|
||||
|
||||
# Set decrypted API key for new conversations (needed for V1 callback processor)
|
||||
if isinstance(jira_dc_view, JiraDcNewConversationView):
|
||||
api_key = self.token_manager.decrypt_text(
|
||||
jira_dc_view.jira_dc_workspace.svc_acc_api_key
|
||||
)
|
||||
jira_dc_view._decrypted_api_key = api_key
|
||||
|
||||
# Create conversation using V1 app conversation system
|
||||
# The callback processor is registered automatically by the view
|
||||
# Create conversation
|
||||
conversation_id = await jira_dc_view.create_or_update_conversation(
|
||||
self.jinja_env
|
||||
)
|
||||
@@ -379,6 +376,21 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
f'[Jira DC] Created/Updated conversation {conversation_id} for issue {jira_dc_view.job_context.issue_key}'
|
||||
)
|
||||
|
||||
if isinstance(jira_dc_view, JiraDcNewConversationView):
|
||||
# Register callback processor for updates
|
||||
processor = JiraDcCallbackProcessor(
|
||||
issue_key=jira_dc_view.job_context.issue_key,
|
||||
workspace_name=jira_dc_view.jira_dc_workspace.name,
|
||||
base_api_url=jira_dc_view.job_context.base_api_url,
|
||||
)
|
||||
|
||||
# Register the callback processor
|
||||
register_callback_processor(conversation_id, processor)
|
||||
|
||||
logger.info(
|
||||
f'[Jira DC] Created callback processor for conversation {conversation_id}'
|
||||
)
|
||||
|
||||
# Send initial response
|
||||
msg_info = jira_dc_view.get_response_msg()
|
||||
|
||||
@@ -456,8 +468,7 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
"""
|
||||
url = f'{base_api_url}/rest/api/2/issue/{issue_key}/comment'
|
||||
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
|
||||
# Convert standard Markdown to Jira Wiki Markup for proper rendering
|
||||
data = {'body': markdown_to_jira_markup(message)}
|
||||
data = {'body': message}
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -5,7 +5,7 @@ from jinja2 import Environment
|
||||
from storage.jira_dc_user import JiraDcUser
|
||||
from storage.jira_dc_workspace import JiraDcWorkspace
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
class JiraDcViewInterface(ABC):
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
"""Jira Data Center V1 callback processor.
|
||||
|
||||
This processor handles events from V1 conversations and posts
|
||||
summaries back to Jira DC issues when the agent finishes work.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from integrations.utils import get_summary_instruction, markdown_to_jira_markup
|
||||
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
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JiraDcV1CallbackProcessor(EventCallbackProcessor):
|
||||
"""Callback processor for Jira Data Center V1 integrations."""
|
||||
|
||||
should_request_summary: bool = Field(default=True)
|
||||
issue_key: str
|
||||
workspace_name: str
|
||||
base_api_url: str
|
||||
svc_acc_api_key: str # Decrypted API key
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
callback: EventCallback,
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for Jira DC V1 integration."""
|
||||
# Only handle ConversationStateUpdateEvent for execution_status
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
|
||||
if event.key != 'execution_status':
|
||||
return None
|
||||
|
||||
_logger.info('[Jira DC] Callback agent state was %s', event)
|
||||
|
||||
# Only request summary when execution has finished successfully
|
||||
if event.value != 'finished':
|
||||
return None
|
||||
|
||||
_logger.info(
|
||||
'[Jira DC] 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'[Jira DC] Requesting summary {conversation_id}')
|
||||
summary = await self._request_summary(conversation_id)
|
||||
_logger.info(
|
||||
f'[Jira DC] Posting summary {conversation_id}',
|
||||
extra={'summary': summary},
|
||||
)
|
||||
await self._post_summary_to_jira_dc(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:
|
||||
_logger.exception(f'[Jira DC] Failed to post summary: {e}', stack_info=True)
|
||||
return EventCallbackResult(
|
||||
status=EventCallbackResultStatus.ERROR,
|
||||
event_callback_id=callback.id,
|
||||
event_id=event.id,
|
||||
conversation_id=conversation_id,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
async def _request_summary(self, conversation_id: UUID) -> str:
|
||||
"""Ask the agent to produce a summary of its work and return the agent response."""
|
||||
# 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,
|
||||
)
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
_logger.exception(
|
||||
'[Jira DC] HTTP error sending message to %s: %s. '
|
||||
'Request payload: %s. Response headers: %s',
|
||||
url,
|
||||
error_detail,
|
||||
payload,
|
||||
dict(e.response.headers),
|
||||
stack_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.exception(
|
||||
'[Jira DC] Timeout error: %s. Request payload: %s',
|
||||
error_detail,
|
||||
payload,
|
||||
stack_info=True,
|
||||
)
|
||||
raise Exception(f'Failed to send message to agent server: {error_detail}')
|
||||
|
||||
async def _post_summary_to_jira_dc(self, summary: str):
|
||||
"""Post the summary back to the Jira DC issue."""
|
||||
if not all(
|
||||
[
|
||||
self.svc_acc_api_key,
|
||||
self.issue_key,
|
||||
self.base_api_url,
|
||||
]
|
||||
):
|
||||
_logger.warning('[Jira DC] Missing required data for posting summary')
|
||||
return
|
||||
|
||||
# Add a comment to the Jira DC issue with the summary
|
||||
comment_url = f'{self.base_api_url}/rest/api/2/issue/{self.issue_key}/comment'
|
||||
|
||||
message = f'OpenHands resolved this issue:\n\n{summary}'
|
||||
# Convert standard Markdown to Jira Wiki Markup for proper rendering
|
||||
comment_body = {'body': markdown_to_jira_markup(message)}
|
||||
|
||||
headers = {'Authorization': f'Bearer {self.svc_acc_api_key}'}
|
||||
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.post(
|
||||
comment_url,
|
||||
headers=headers,
|
||||
json=comment_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
_logger.info(f'[Jira DC] Posted summary to {self.issue_key}')
|
||||
@@ -1,49 +1,34 @@
|
||||
"""Jira Data Center view implementations and factory.
|
||||
|
||||
Views are responsible for:
|
||||
- Holding the webhook payload and auth context
|
||||
- Creating conversations using V1 app conversation system
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import UUID, uuid4
|
||||
from dataclasses import dataclass
|
||||
|
||||
from integrations.jira_dc.jira_dc_types import (
|
||||
JiraDcViewInterface,
|
||||
StartingConvoException,
|
||||
)
|
||||
from integrations.jira_dc.jira_dc_v1_callback_processor import JiraDcV1CallbackProcessor
|
||||
from integrations.models import JobContext
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.utils import CONVERSATION_URL
|
||||
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
|
||||
from jinja2 import Environment
|
||||
from storage.jira_dc_conversation import JiraDcConversation
|
||||
from storage.jira_dc_integration_store import JiraDcIntegrationStore
|
||||
from storage.jira_dc_user import JiraDcUser
|
||||
from storage.jira_dc_workspace import JiraDcWorkspace
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_conversation_settings,
|
||||
)
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
|
||||
integration_store = JiraDcIntegrationStore.get_instance()
|
||||
|
||||
|
||||
@dataclass
|
||||
class JiraDcNewConversationView(JiraDcViewInterface):
|
||||
"""View for creating a new Jira DC conversation."""
|
||||
|
||||
job_context: JobContext
|
||||
saas_user_auth: UserAuth
|
||||
jira_dc_user: JiraDcUser
|
||||
@@ -51,14 +36,9 @@ class JiraDcNewConversationView(JiraDcViewInterface):
|
||||
selected_repo: str | None
|
||||
conversation_id: str
|
||||
|
||||
# Decrypted API key (set by manager)
|
||||
_decrypted_api_key: str = field(default='', repr=False)
|
||||
|
||||
# Resolved org ID for V1 conversations
|
||||
resolved_org_id: UUID | None = None
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized."""
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
instructions_template = jinja_env.get_template('jira_dc_instructions.j2')
|
||||
instructions = instructions_template.render()
|
||||
|
||||
@@ -74,148 +54,58 @@ class JiraDcNewConversationView(JiraDcViewInterface):
|
||||
return instructions, user_msg
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Create a new Jira DC conversation using V1 app conversation system.
|
||||
"""Create a new Jira DC conversation"""
|
||||
|
||||
Returns:
|
||||
The conversation ID
|
||||
|
||||
Raises:
|
||||
StartingConvoException: If conversation creation fails
|
||||
"""
|
||||
if not self.selected_repo:
|
||||
raise StartingConvoException('No repository selected for this conversation')
|
||||
|
||||
# Generate conversation ID
|
||||
self.conversation_id = uuid4().hex
|
||||
|
||||
# Save the JiraDC conversation mapping
|
||||
jira_dc_conversation = JiraDcConversation(
|
||||
conversation_id=self.conversation_id,
|
||||
issue_id=self.job_context.issue_id,
|
||||
issue_key=self.job_context.issue_key,
|
||||
jira_dc_user_id=self.jira_dc_user.id,
|
||||
)
|
||||
await integration_store.create_conversation(jira_dc_conversation)
|
||||
|
||||
# Create V1 conversation
|
||||
await self._create_v1_conversation(jinja_env)
|
||||
return self.conversation_id
|
||||
|
||||
async def _create_v1_conversation(self, jinja_env: Environment):
|
||||
"""Create conversation using the V1 app conversation system."""
|
||||
logger.info('[Jira DC]: Creating V1 conversation')
|
||||
|
||||
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)
|
||||
|
||||
# Create the initial message request
|
||||
initial_message = SendMessageRequest(
|
||||
role='user', content=[TextContent(text=user_msg)]
|
||||
)
|
||||
|
||||
# Create the Jira DC V1 callback processor
|
||||
jira_dc_callback_processor = self._create_jira_dc_v1_callback_processor()
|
||||
|
||||
# Resolve org ID for the V1 system
|
||||
self.resolved_org_id = await self._get_resolved_org_id()
|
||||
|
||||
# Determine git provider
|
||||
git_provider = await self._get_git_provider()
|
||||
|
||||
injector_state = InjectorState()
|
||||
|
||||
# Create the V1 conversation start request
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=UUID(self.conversation_id),
|
||||
system_message_suffix=instructions if instructions else None,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
git_provider=git_provider,
|
||||
title=f'Jira DC Issue {self.job_context.issue_key}: {self.job_context.issue_title or "Unknown"}',
|
||||
trigger=ConversationTrigger.JIRA,
|
||||
processors=[jira_dc_callback_processor],
|
||||
)
|
||||
|
||||
# Set up the Jira DC user context for the V1 system
|
||||
jira_dc_user_context = ResolverUserContext(
|
||||
saas_user_auth=self.saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, jira_dc_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}'
|
||||
)
|
||||
|
||||
logger.info(f'[Jira DC]: Created new conversation: {self.conversation_id}')
|
||||
|
||||
def _create_jira_dc_v1_callback_processor(self) -> JiraDcV1CallbackProcessor:
|
||||
"""Create a V1 callback processor for Jira DC integration."""
|
||||
return JiraDcV1CallbackProcessor(
|
||||
issue_key=self.job_context.issue_key,
|
||||
workspace_name=self.jira_dc_workspace.name,
|
||||
base_api_url=self.job_context.base_api_url,
|
||||
svc_acc_api_key=self._decrypted_api_key,
|
||||
)
|
||||
|
||||
async def _get_git_provider(self) -> ProviderType | None:
|
||||
"""Determine the git provider from the selected repository."""
|
||||
if not self.selected_repo:
|
||||
return None
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if not provider_tokens:
|
||||
return None
|
||||
|
||||
try:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
return repository.git_provider
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Jira DC] Failed to determine git provider for {self.selected_repo}: {e}'
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=self.jira_dc_user.keycloak_user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
initial_user_msg=user_msg,
|
||||
conversation_instructions=instructions,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_trigger=ConversationTrigger.JIRA_DC,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
)
|
||||
return None
|
||||
|
||||
async def _get_resolved_org_id(self) -> UUID | None:
|
||||
"""Resolve the org ID for V1 conversations."""
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if not provider_tokens or not self.selected_repo:
|
||||
return None
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
|
||||
try:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
resolved_org_id = await resolve_org_for_repo(
|
||||
provider=repository.git_provider.value,
|
||||
full_repo_name=self.selected_repo,
|
||||
keycloak_user_id=self.jira_dc_user.keycloak_user_id,
|
||||
logger.info(f'[Jira DC] Created conversation {self.conversation_id}')
|
||||
|
||||
# Store Jira DC conversation mapping
|
||||
jira_dc_conversation = JiraDcConversation(
|
||||
conversation_id=self.conversation_id,
|
||||
issue_id=self.job_context.issue_id,
|
||||
issue_key=self.job_context.issue_key,
|
||||
jira_dc_user_id=self.jira_dc_user.id,
|
||||
)
|
||||
return resolved_org_id
|
||||
|
||||
await integration_store.create_conversation(jira_dc_conversation)
|
||||
|
||||
return self.conversation_id
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Jira DC] Failed to resolve org for {self.selected_repo}: {e}'
|
||||
logger.error(
|
||||
f'[Jira DC] Failed to create conversation: {str(e)}', exc_info=True
|
||||
)
|
||||
return None
|
||||
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
"""Get the response message to send back to Jira DC."""
|
||||
"""Get the response message to send back to Jira DC"""
|
||||
conversation_link = CONVERSATION_URL.format(self.conversation_id)
|
||||
return f"I'm on it! {self.job_context.display_name} can [track my progress here|{conversation_link}]."
|
||||
|
||||
|
||||
@dataclass
|
||||
class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
"""View for sending messages to an existing Jira DC conversation."""
|
||||
|
||||
job_context: JobContext
|
||||
saas_user_auth: UserAuth
|
||||
jira_dc_user: JiraDcUser
|
||||
@@ -224,7 +114,8 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
conversation_id: str
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is updated."""
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
user_msg_template = jinja_env.get_template('jira_dc_existing_conversation.j2')
|
||||
user_msg = user_msg_template.render(
|
||||
issue_key=self.job_context.issue_key,
|
||||
@@ -236,107 +127,64 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
return '', user_msg
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Send a message to an existing V1 conversation.
|
||||
"""Update an existing Jira conversation"""
|
||||
|
||||
Returns:
|
||||
The conversation ID
|
||||
"""
|
||||
await self._send_message_to_v1_conversation(jinja_env)
|
||||
return self.conversation_id
|
||||
user_id = self.jira_dc_user.keycloak_user_id
|
||||
|
||||
async def _send_message_to_v1_conversation(self, jinja_env: Environment):
|
||||
"""Send a message to an existing V1 conversation using the agent server API."""
|
||||
import httpx
|
||||
|
||||
from openhands.app_server.config import (
|
||||
get_app_conversation_info_service,
|
||||
get_httpx_client,
|
||||
get_sandbox_service,
|
||||
)
|
||||
from openhands.app_server.event_callback.util import (
|
||||
ensure_conversation_found,
|
||||
get_agent_server_url_from_sandbox,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import (
|
||||
ADMIN,
|
||||
USER_CONTEXT_ATTR,
|
||||
)
|
||||
|
||||
_, user_msg = await self._get_instructions(jinja_env)
|
||||
|
||||
# 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
|
||||
conversation_uuid = UUID(self.conversation_id)
|
||||
app_conversation_info = ensure_conversation_found(
|
||||
await app_conversation_info_service.get_app_conversation_info(
|
||||
conversation_uuid
|
||||
),
|
||||
conversation_uuid,
|
||||
try:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
|
||||
# 2. Sandbox lookup + validation
|
||||
sandbox = await sandbox_service.get_sandbox(
|
||||
app_conversation_info.sandbox_id
|
||||
)
|
||||
|
||||
if sandbox is None or sandbox.status != SandboxStatus.RUNNING:
|
||||
logger.warning(
|
||||
f'[Jira DC] Sandbox not running for conversation {self.conversation_id}'
|
||||
)
|
||||
return
|
||||
|
||||
if sandbox.session_api_key is None:
|
||||
logger.warning(
|
||||
f'[Jira DC] No session API key for sandbox: {sandbox.id}'
|
||||
)
|
||||
return
|
||||
|
||||
# 3. Build URL and send message
|
||||
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
|
||||
|
||||
send_message_request = SendMessageRequest(
|
||||
role='user', content=[TextContent(text=user_msg)]
|
||||
)
|
||||
|
||||
url = (
|
||||
f"{agent_server_url.rstrip('/')}"
|
||||
f'/api/conversations/{self.conversation_id}/messages'
|
||||
)
|
||||
headers = {'X-Session-API-Key': sandbox.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()
|
||||
logger.info(
|
||||
f'[Jira DC] Sent message to existing conversation {self.conversation_id}'
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
f'[Jira DC] Failed to send message: HTTP {e.response.status_code}'
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'[Jira DC] Failed to send message: {e}')
|
||||
raise
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if provider_tokens is None:
|
||||
raise ValueError('Could not load provider tokens')
|
||||
providers_set = list(provider_tokens.keys())
|
||||
|
||||
conversation_init_data = await setup_init_conversation_settings(
|
||||
user_id, self.conversation_id, providers_set
|
||||
)
|
||||
|
||||
# Either join ongoing conversation, or restart the conversation
|
||||
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
|
||||
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
|
||||
)
|
||||
agent_state = (
|
||||
None
|
||||
if len(final_agent_observation) == 0
|
||||
else final_agent_observation[0].agent_state
|
||||
)
|
||||
|
||||
if not agent_state or agent_state == AgentState.LOADING:
|
||||
raise StartingConvoException('Conversation is still starting')
|
||||
|
||||
_, user_msg = await 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)
|
||||
)
|
||||
|
||||
return self.conversation_id
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Jira] Failed to create conversation: {str(e)}', exc_info=True
|
||||
)
|
||||
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
"""Get the response message to send back to Jira."""
|
||||
"""Get the response message to send back to Jira"""
|
||||
conversation_link = CONVERSATION_URL.format(self.conversation_id)
|
||||
return f"I'm on it! {self.job_context.display_name} can [continue tracking my progress here|{conversation_link}]."
|
||||
|
||||
@@ -352,6 +200,7 @@ class JiraDcFactory:
|
||||
jira_dc_workspace: JiraDcWorkspace,
|
||||
) -> JiraDcViewInterface:
|
||||
"""Create appropriate Jira DC view based on the payload."""
|
||||
|
||||
if not jira_dc_user or not saas_user_auth or not jira_dc_workspace:
|
||||
raise StartingConvoException('User not authenticated with Jira integration')
|
||||
|
||||
|
||||
536
enterprise/integrations/linear/linear_manager.py
Normal file
536
enterprise/integrations/linear/linear_manager.py
Normal file
@@ -0,0 +1,536 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from integrations.linear.linear_types import LinearViewInterface
|
||||
from integrations.linear.linear_view import (
|
||||
LinearExistingConversationView,
|
||||
LinearFactory,
|
||||
LinearNewConversationView,
|
||||
)
|
||||
from integrations.manager import Manager
|
||||
from integrations.models import JobContext, Message
|
||||
from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.utils.conversation_callback_utils import register_callback_processor
|
||||
from storage.linear_integration_store import LinearIntegrationStore
|
||||
from storage.linear_user import LinearUser
|
||||
from storage.linear_workspace import LinearWorkspace
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
|
||||
class LinearManager(Manager[LinearViewInterface]):
|
||||
def __init__(self, token_manager: TokenManager):
|
||||
self.token_manager = token_manager
|
||||
self.integration_store = LinearIntegrationStore.get_instance()
|
||||
self.api_url = 'https://api.linear.app/graphql'
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR + 'linear')
|
||||
)
|
||||
|
||||
async def authenticate_user(
|
||||
self, linear_user_id: str, workspace_id: int
|
||||
) -> tuple[LinearUser | None, UserAuth | None]:
|
||||
"""Authenticate Linear user and get their OpenHands user auth."""
|
||||
|
||||
# Find active Linear user by Linear user ID and workspace ID
|
||||
linear_user = await self.integration_store.get_active_user(
|
||||
linear_user_id, workspace_id
|
||||
)
|
||||
|
||||
if not linear_user:
|
||||
logger.warning(
|
||||
f'[Linear] No active Linear user found for {linear_user_id} in workspace {workspace_id}'
|
||||
)
|
||||
return None, None
|
||||
|
||||
saas_user_auth = await get_user_auth_from_keycloak_id(
|
||||
linear_user.keycloak_user_id
|
||||
)
|
||||
return linear_user, saas_user_auth
|
||||
|
||||
async def _get_repositories(self, user_auth: UserAuth) -> list[Repository]:
|
||||
"""Get repositories that the user has access to."""
|
||||
provider_tokens = await user_auth.get_provider_tokens()
|
||||
if provider_tokens is None:
|
||||
return []
|
||||
access_token = await user_auth.get_access_token()
|
||||
user_id = await user_auth.get_user_id()
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
repos: list[Repository] = await client.get_repositories(
|
||||
'pushed', server_config.app_mode, None, None, None, None
|
||||
)
|
||||
return repos
|
||||
|
||||
async def validate_request(
|
||||
self, request: Request
|
||||
) -> Tuple[bool, Optional[str], Optional[Dict]]:
|
||||
"""Verify Linear webhook signature."""
|
||||
signature = request.headers.get('linear-signature')
|
||||
body = await request.body()
|
||||
payload = await request.json()
|
||||
actor_url = payload.get('actor', {}).get('url', '')
|
||||
workspace_name = ''
|
||||
|
||||
# Extract workspace name from actor URL
|
||||
# Format: https://linear.app/{workspace}/profiles/{user}
|
||||
if actor_url.startswith('https://linear.app/'):
|
||||
url_parts = actor_url.split('/')
|
||||
if len(url_parts) >= 4:
|
||||
workspace_name = url_parts[3] # Extract workspace name
|
||||
else:
|
||||
logger.warning(f'[Linear] Invalid actor URL format: {actor_url}')
|
||||
return False, None, None
|
||||
else:
|
||||
logger.warning(
|
||||
f'[Linear] Actor URL does not match expected format: {actor_url}'
|
||||
)
|
||||
return False, None, None
|
||||
|
||||
if not workspace_name:
|
||||
logger.warning('[Linear] No workspace name found in webhook payload')
|
||||
return False, None, None
|
||||
|
||||
if not signature:
|
||||
logger.warning('[Linear] No signature found in webhook headers')
|
||||
return False, None, None
|
||||
|
||||
workspace = await self.integration_store.get_workspace_by_name(workspace_name)
|
||||
|
||||
if not workspace:
|
||||
logger.warning('[Linear] Could not identify workspace for webhook')
|
||||
return False, None, None
|
||||
|
||||
if workspace.status != 'active':
|
||||
logger.warning(f'[Linear] Workspace {workspace.id} is not active')
|
||||
return False, None, None
|
||||
|
||||
webhook_secret = self.token_manager.decrypt_text(workspace.webhook_secret)
|
||||
digest = hmac.new(webhook_secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
if hmac.compare_digest(signature, digest):
|
||||
logger.info('[Linear] Webhook signature verified successfully')
|
||||
return True, signature, payload
|
||||
|
||||
return False, None, None
|
||||
|
||||
def parse_webhook(self, payload: Dict) -> JobContext | None:
|
||||
action = payload.get('action')
|
||||
type = payload.get('type')
|
||||
|
||||
if action == 'create' and type == 'Comment':
|
||||
data = payload.get('data', {})
|
||||
comment = data.get('body', '')
|
||||
|
||||
if '@openhands' not in comment:
|
||||
return None
|
||||
|
||||
issue_data = data.get('issue', {})
|
||||
issue_id = issue_data.get('id', '')
|
||||
issue_key = issue_data.get('identifier', '')
|
||||
elif action == 'update' and type == 'Issue':
|
||||
data = payload.get('data', {})
|
||||
labels = data.get('labels', [])
|
||||
|
||||
has_openhands_label = False
|
||||
label_id = ''
|
||||
for label in labels:
|
||||
if label.get('name') == 'openhands':
|
||||
label_id = label.get('id', '')
|
||||
has_openhands_label = True
|
||||
break
|
||||
|
||||
if not has_openhands_label and not label_id:
|
||||
return None
|
||||
|
||||
labelIdChanges = data.get('updatedFrom', {}).get('labelIds', [])
|
||||
|
||||
if labelIdChanges and label_id in labelIdChanges:
|
||||
return None # Label was added previously, ignore this webhook
|
||||
|
||||
issue_id = data.get('id', '')
|
||||
issue_key = data.get('identifier', '')
|
||||
comment = ''
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
actor = payload.get('actor', {})
|
||||
display_name = actor.get('name', '')
|
||||
user_email = actor.get('email', '')
|
||||
actor_url = actor.get('url', '')
|
||||
actor_id = actor.get('id', '')
|
||||
workspace_name = ''
|
||||
|
||||
if actor_url.startswith('https://linear.app/'):
|
||||
url_parts = actor_url.split('/')
|
||||
if len(url_parts) >= 4:
|
||||
workspace_name = url_parts[3] # Extract workspace name
|
||||
else:
|
||||
logger.warning(f'[Linear] Invalid actor URL format: {actor_url}')
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f'[Linear] Actor URL does not match expected format: {actor_url}'
|
||||
)
|
||||
return None
|
||||
|
||||
if not all(
|
||||
[issue_id, issue_key, display_name, user_email, actor_id, workspace_name]
|
||||
):
|
||||
logger.warning('[Linear] Missing required fields in webhook payload')
|
||||
return None
|
||||
|
||||
return JobContext(
|
||||
issue_id=issue_id,
|
||||
issue_key=issue_key,
|
||||
user_msg=comment,
|
||||
user_email=user_email,
|
||||
platform_user_id=actor_id,
|
||||
workspace_name=workspace_name,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
async def receive_message(self, message: Message):
|
||||
"""Process incoming Linear webhook message."""
|
||||
payload = message.message.get('payload', {})
|
||||
job_context = self.parse_webhook(payload)
|
||||
|
||||
if not job_context:
|
||||
logger.info('[Linear] Webhook does not match trigger conditions')
|
||||
return
|
||||
|
||||
# Get workspace by user email domain
|
||||
workspace = await self.integration_store.get_workspace_by_name(
|
||||
job_context.workspace_name
|
||||
)
|
||||
if not workspace:
|
||||
logger.warning(
|
||||
f'[Linear] No workspace found for email domain: {job_context.workspace_name}'
|
||||
)
|
||||
await self._send_error_comment(
|
||||
job_context.issue_id,
|
||||
'Your workspace is not configured with Linear integration.',
|
||||
None,
|
||||
)
|
||||
return
|
||||
|
||||
# Prevent any recursive triggers from the service account
|
||||
if job_context.user_email == workspace.svc_acc_email:
|
||||
return
|
||||
|
||||
if workspace.status != 'active':
|
||||
logger.warning(f'[Linear] Workspace {workspace.id} is not active')
|
||||
await self._send_error_comment(
|
||||
job_context.issue_id,
|
||||
'Linear integration is not active for your workspace.',
|
||||
workspace,
|
||||
)
|
||||
return
|
||||
|
||||
# Authenticate user
|
||||
linear_user, saas_user_auth = await self.authenticate_user(
|
||||
job_context.platform_user_id, workspace.id
|
||||
)
|
||||
if not linear_user or not saas_user_auth:
|
||||
logger.warning(
|
||||
f'[Linear] User authentication failed for {job_context.user_email}'
|
||||
)
|
||||
await self._send_error_comment(
|
||||
job_context.issue_id,
|
||||
f'User {job_context.user_email} is not authenticated or active in the Linear integration.',
|
||||
workspace,
|
||||
)
|
||||
return
|
||||
|
||||
# Get issue details
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
|
||||
issue_title, issue_description = await self.get_issue_details(
|
||||
job_context.issue_id, api_key
|
||||
)
|
||||
job_context.issue_title = issue_title
|
||||
job_context.issue_description = issue_description
|
||||
except Exception as e:
|
||||
logger.error(f'[Linear] Failed to get issue context: {str(e)}')
|
||||
await self._send_error_comment(
|
||||
job_context.issue_id,
|
||||
'Failed to retrieve issue details. Please check the issue ID and try again.',
|
||||
workspace,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Create Linear view
|
||||
linear_view = await LinearFactory.create_linear_view_from_payload(
|
||||
job_context,
|
||||
saas_user_auth,
|
||||
linear_user,
|
||||
workspace,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Linear] Failed to create linear view: {str(e)}', exc_info=True
|
||||
)
|
||||
await self._send_error_comment(
|
||||
job_context.issue_id,
|
||||
'Failed to initialize conversation. Please try again.',
|
||||
workspace,
|
||||
)
|
||||
return
|
||||
|
||||
if not await self.is_job_requested(message, linear_view):
|
||||
return
|
||||
|
||||
await self.start_job(linear_view)
|
||||
|
||||
async def is_job_requested(
|
||||
self, message: Message, linear_view: LinearViewInterface
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a job is requested and handle repository selection.
|
||||
"""
|
||||
|
||||
if isinstance(linear_view, LinearExistingConversationView):
|
||||
return True
|
||||
|
||||
try:
|
||||
# Get user repositories
|
||||
user_repos: list[Repository] = await self._get_repositories(
|
||||
linear_view.saas_user_auth
|
||||
)
|
||||
|
||||
target_str = f'{linear_view.job_context.issue_description}\n{linear_view.job_context.user_msg}'
|
||||
|
||||
# Try to infer repository from issue description
|
||||
match, repos = filter_potential_repos_by_user_msg(target_str, user_repos)
|
||||
|
||||
if match:
|
||||
# Found exact repository match
|
||||
linear_view.selected_repo = repos[0].full_name
|
||||
logger.info(f'[Linear] Inferred repository: {repos[0].full_name}')
|
||||
return True
|
||||
else:
|
||||
# No clear match - send repository selection comment
|
||||
await self._send_repo_selection_comment(linear_view)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'[Linear] Error in is_job_requested: {str(e)}')
|
||||
return False
|
||||
|
||||
async def start_job(self, linear_view: LinearViewInterface) -> None:
|
||||
"""Start a Linear job/conversation."""
|
||||
# Import here to prevent circular import
|
||||
from server.conversation_callback_processor.linear_callback_processor import (
|
||||
LinearCallbackProcessor,
|
||||
)
|
||||
|
||||
try:
|
||||
user_info: LinearUser = linear_view.linear_user
|
||||
logger.info(
|
||||
f'[Linear] Starting job for user {user_info.keycloak_user_id} '
|
||||
f'issue {linear_view.job_context.issue_key}',
|
||||
)
|
||||
|
||||
# Create conversation
|
||||
conversation_id = await linear_view.create_or_update_conversation(
|
||||
self.jinja_env
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Linear] Created/Updated conversation {conversation_id} for issue {linear_view.job_context.issue_key}'
|
||||
)
|
||||
|
||||
if isinstance(linear_view, LinearNewConversationView):
|
||||
# Register callback processor for updates
|
||||
processor = LinearCallbackProcessor(
|
||||
issue_id=linear_view.job_context.issue_id,
|
||||
issue_key=linear_view.job_context.issue_key,
|
||||
workspace_name=linear_view.linear_workspace.name,
|
||||
)
|
||||
|
||||
# Register the callback processor
|
||||
register_callback_processor(conversation_id, processor)
|
||||
|
||||
logger.info(
|
||||
f'[Linear] Created callback processor for conversation {conversation_id}'
|
||||
)
|
||||
|
||||
# Send initial response
|
||||
msg_info = linear_view.get_response_msg()
|
||||
|
||||
except MissingSettingsError as e:
|
||||
logger.warning(f'[Linear] Missing settings error: {str(e)}')
|
||||
msg_info = f'Please re-login into [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except LLMAuthenticationError as e:
|
||||
logger.warning(f'[Linear] LLM authentication error: {str(e)}')
|
||||
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(f'[Linear] Session expired: {str(e)}')
|
||||
msg_info = get_session_expired_message()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Linear] Unexpected error starting job: {str(e)}', exc_info=True
|
||||
)
|
||||
msg_info = 'Sorry, there was an unexpected error starting the job. Please try again.'
|
||||
|
||||
# Send response comment
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(
|
||||
linear_view.linear_workspace.svc_acc_api_key
|
||||
)
|
||||
await self.send_message(
|
||||
msg_info,
|
||||
linear_view.job_context.issue_id,
|
||||
api_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'[Linear] Failed to send response message: {str(e)}')
|
||||
|
||||
async def _query_api(self, query: str, variables: Dict, api_key: str) -> Dict:
|
||||
"""Query Linear GraphQL API."""
|
||||
headers = {'Authorization': api_key}
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.post(
|
||||
self.api_url,
|
||||
headers=headers,
|
||||
json={'query': query, 'variables': variables},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_issue_details(self, issue_id: str, api_key: str) -> Tuple[str, str]:
|
||||
"""Get issue details from Linear API."""
|
||||
query = """
|
||||
query Issue($issueId: String!) {
|
||||
issue(id: $issueId) {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
syncedWith {
|
||||
metadata {
|
||||
... on ExternalEntityInfoGithubMetadata {
|
||||
owner
|
||||
repo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
issue_payload = await self._query_api(query, {'issueId': issue_id}, api_key)
|
||||
|
||||
if not issue_payload:
|
||||
raise ValueError(f'Issue with ID {issue_id} not found.')
|
||||
|
||||
issue_data = issue_payload.get('data', {}).get('issue', {})
|
||||
title = issue_data.get('title', '')
|
||||
description = issue_data.get('description', '')
|
||||
synced_with = issue_data.get('syncedWith', [])
|
||||
owner = ''
|
||||
repo = ''
|
||||
if synced_with:
|
||||
owner = synced_with[0].get('metadata', {}).get('owner', '')
|
||||
repo = synced_with[0].get('metadata', {}).get('repo', '')
|
||||
|
||||
if not title:
|
||||
raise ValueError(f'Issue with ID {issue_id} does not have a title.')
|
||||
|
||||
if not description:
|
||||
raise ValueError(f'Issue with ID {issue_id} does not have a description.')
|
||||
|
||||
if owner and repo:
|
||||
description += f'\n\nGit Repo: {owner}/{repo}'
|
||||
|
||||
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
|
||||
"""
|
||||
query = """
|
||||
mutation CommentCreate($input: CommentCreateInput!) {
|
||||
commentCreate(input: $input) {
|
||||
success
|
||||
comment {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {'input': {'issueId': issue_id, 'body': message}}
|
||||
return await self._query_api(query, variables, api_key)
|
||||
|
||||
async def _send_error_comment(
|
||||
self, issue_id: str, error_msg: str, workspace: LinearWorkspace | None
|
||||
):
|
||||
"""Send error comment to Linear issue."""
|
||||
if not workspace:
|
||||
logger.error('[Linear] Cannot send error comment - no workspace available')
|
||||
return
|
||||
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
|
||||
await self.send_message(error_msg, issue_id, api_key)
|
||||
except Exception as e:
|
||||
logger.error(f'[Linear] Failed to send error comment: {str(e)}')
|
||||
|
||||
async def _send_repo_selection_comment(self, linear_view: LinearViewInterface):
|
||||
"""Send a comment with repository options for the user to choose."""
|
||||
try:
|
||||
comment_msg = (
|
||||
'I need to know which repository to work with. '
|
||||
'Please add it to your issue description or send a followup comment.'
|
||||
)
|
||||
|
||||
api_key = self.token_manager.decrypt_text(
|
||||
linear_view.linear_workspace.svc_acc_api_key
|
||||
)
|
||||
|
||||
await self.send_message(
|
||||
comment_msg,
|
||||
linear_view.job_context.issue_id,
|
||||
api_key,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Linear] Sent repository selection comment for issue {linear_view.job_context.issue_key}'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Linear] Failed to send repository selection comment: {str(e)}'
|
||||
)
|
||||
40
enterprise/integrations/linear/linear_types.py
Normal file
40
enterprise/integrations/linear/linear_types.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from integrations.models import JobContext
|
||||
from jinja2 import Environment
|
||||
from storage.linear_user import LinearUser
|
||||
from storage.linear_workspace import LinearWorkspace
|
||||
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
class LinearViewInterface(ABC):
|
||||
"""Interface for Linear views that handle different types of Linear interactions."""
|
||||
|
||||
job_context: JobContext
|
||||
saas_user_auth: UserAuth
|
||||
linear_user: LinearUser
|
||||
linear_workspace: LinearWorkspace
|
||||
selected_repo: str | None
|
||||
conversation_id: str
|
||||
|
||||
@abstractmethod
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Get initial instructions for the conversation."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Create or update a conversation and return the conversation ID."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_response_msg(self) -> str:
|
||||
"""Get the response message to send back to Linear."""
|
||||
pass
|
||||
|
||||
|
||||
class StartingConvoException(Exception):
|
||||
"""Exception raised when starting a conversation fails."""
|
||||
|
||||
pass
|
||||
229
enterprise/integrations/linear/linear_view.py
Normal file
229
enterprise/integrations/linear/linear_view.py
Normal file
@@ -0,0 +1,229 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from integrations.linear.linear_types import LinearViewInterface, StartingConvoException
|
||||
from integrations.models import JobContext
|
||||
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
|
||||
from jinja2 import Environment
|
||||
from storage.linear_conversation import LinearConversation
|
||||
from storage.linear_integration_store import LinearIntegrationStore
|
||||
from storage.linear_user import LinearUser
|
||||
from storage.linear_workspace import LinearWorkspace
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_conversation_settings,
|
||||
)
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
|
||||
integration_store = LinearIntegrationStore.get_instance()
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinearNewConversationView(LinearViewInterface):
|
||||
job_context: JobContext
|
||||
saas_user_auth: UserAuth
|
||||
linear_user: LinearUser
|
||||
linear_workspace: LinearWorkspace
|
||||
selected_repo: str | None
|
||||
conversation_id: str
|
||||
|
||||
async 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')
|
||||
instructions = instructions_template.render()
|
||||
|
||||
user_msg_template = jinja_env.get_template('linear_new_conversation.j2')
|
||||
|
||||
user_msg = user_msg_template.render(
|
||||
issue_key=self.job_context.issue_key,
|
||||
issue_title=self.job_context.issue_title,
|
||||
issue_description=self.job_context.issue_description,
|
||||
user_message=self.job_context.user_msg or '',
|
||||
)
|
||||
|
||||
return instructions, user_msg
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Create a new Linear conversation"""
|
||||
|
||||
if not self.selected_repo:
|
||||
raise StartingConvoException('No repository selected for this conversation')
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=self.linear_user.keycloak_user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
initial_user_msg=user_msg,
|
||||
conversation_instructions=instructions,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_trigger=ConversationTrigger.LINEAR,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
)
|
||||
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
|
||||
logger.info(f'[Linear] Created conversation {self.conversation_id}')
|
||||
|
||||
# Store Linear conversation mapping
|
||||
linear_conversation = LinearConversation(
|
||||
conversation_id=self.conversation_id,
|
||||
issue_id=self.job_context.issue_id,
|
||||
issue_key=self.job_context.issue_key,
|
||||
linear_user_id=self.linear_user.id,
|
||||
)
|
||||
|
||||
await integration_store.create_conversation(linear_conversation)
|
||||
|
||||
return self.conversation_id
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Linear] Failed to create conversation: {str(e)}', exc_info=True
|
||||
)
|
||||
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
"""Get the response message to send back to Linear"""
|
||||
conversation_link = CONVERSATION_URL.format(self.conversation_id)
|
||||
return f"I'm on it! {self.job_context.display_name} can [track my progress here]({conversation_link})."
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinearExistingConversationView(LinearViewInterface):
|
||||
job_context: JobContext
|
||||
saas_user_auth: UserAuth
|
||||
linear_user: LinearUser
|
||||
linear_workspace: LinearWorkspace
|
||||
selected_repo: str | None
|
||||
conversation_id: str
|
||||
|
||||
async 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')
|
||||
user_msg = user_msg_template.render(
|
||||
issue_key=self.job_context.issue_key,
|
||||
user_message=self.job_context.user_msg or '',
|
||||
issue_title=self.job_context.issue_title,
|
||||
issue_description=self.job_context.issue_description,
|
||||
)
|
||||
|
||||
return '', user_msg
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Update an existing Linear conversation"""
|
||||
|
||||
user_id = self.linear_user.keycloak_user_id
|
||||
|
||||
try:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if provider_tokens is None:
|
||||
raise ValueError('Could not load provider tokens')
|
||||
providers_set = list(provider_tokens.keys())
|
||||
|
||||
conversation_init_data = await setup_init_conversation_settings(
|
||||
user_id, self.conversation_id, providers_set
|
||||
)
|
||||
|
||||
# Either join ongoing conversation, or restart the conversation
|
||||
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
|
||||
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
|
||||
)
|
||||
agent_state = (
|
||||
None
|
||||
if len(final_agent_observation) == 0
|
||||
else final_agent_observation[0].agent_state
|
||||
)
|
||||
|
||||
if not agent_state or agent_state == AgentState.LOADING:
|
||||
raise StartingConvoException('Conversation is still starting')
|
||||
|
||||
_, user_msg = await 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)
|
||||
)
|
||||
|
||||
return self.conversation_id
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Linear] Failed to create conversation: {str(e)}', exc_info=True
|
||||
)
|
||||
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
"""Get the response message to send back to Linear"""
|
||||
conversation_link = CONVERSATION_URL.format(self.conversation_id)
|
||||
return f"I'm on it! {self.job_context.display_name} can [continue tracking my progress here]({conversation_link})."
|
||||
|
||||
|
||||
class LinearFactory:
|
||||
"""Factory for creating Linear views based on message content"""
|
||||
|
||||
@staticmethod
|
||||
async def create_linear_view_from_payload(
|
||||
job_context: JobContext,
|
||||
saas_user_auth: UserAuth,
|
||||
linear_user: LinearUser,
|
||||
linear_workspace: LinearWorkspace,
|
||||
) -> LinearViewInterface:
|
||||
"""Create appropriate Linear view based on the message and user state"""
|
||||
|
||||
if not linear_user or not saas_user_auth or not linear_workspace:
|
||||
raise StartingConvoException(
|
||||
'User not authenticated with Linear integration'
|
||||
)
|
||||
|
||||
conversation = await integration_store.get_user_conversations_by_issue_id(
|
||||
job_context.issue_id, linear_user.id
|
||||
)
|
||||
if conversation:
|
||||
logger.info(
|
||||
f'[Linear] Found existing conversation for issue {job_context.issue_id}'
|
||||
)
|
||||
return LinearExistingConversationView(
|
||||
job_context=job_context,
|
||||
saas_user_auth=saas_user_auth,
|
||||
linear_user=linear_user,
|
||||
linear_workspace=linear_workspace,
|
||||
selected_repo=None,
|
||||
conversation_id=conversation.conversation_id,
|
||||
)
|
||||
|
||||
return LinearNewConversationView(
|
||||
job_context=job_context,
|
||||
saas_user_auth=saas_user_auth,
|
||||
linear_user=linear_user,
|
||||
linear_workspace=linear_workspace,
|
||||
selected_repo=None, # Will be set later after repo inference
|
||||
conversation_id='', # Will be set when conversation is created
|
||||
)
|
||||
@@ -1,14 +1,9 @@
|
||||
from uuid import UUID
|
||||
|
||||
from openhands.app_server.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
)
|
||||
from openhands.app_server.integrations.service_types import ProviderType, UserGitInfo
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.sdk.secret import SecretSource, StaticSecret
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
class ResolverUserContext(UserContext):
|
||||
@@ -17,10 +12,8 @@ class ResolverUserContext(UserContext):
|
||||
def __init__(
|
||||
self,
|
||||
saas_user_auth: UserAuth,
|
||||
resolver_org_id: UUID | None = None,
|
||||
):
|
||||
self.saas_user_auth = saas_user_auth
|
||||
self.resolver_org_id = resolver_org_id
|
||||
self._provider_handler: ProviderHandler | None = None
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
@@ -88,6 +81,3 @@ class ResolverUserContext(UserContext):
|
||||
|
||||
async def get_mcp_api_key(self) -> str | None:
|
||||
return await self.saas_user_auth.get_mcp_api_key()
|
||||
|
||||
async def get_user_git_info(self) -> UserGitInfo | None:
|
||||
return await self.saas_user_auth.get_user_git_info()
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Resolve which OpenHands organization workspace a resolver conversation should be created in.
|
||||
|
||||
This module provides a reusable utility for routing resolver conversations
|
||||
(GitHub, GitLab, Bitbucket, Slack, etc.) to the correct OpenHands organization
|
||||
workspace based on claimed Git organizations.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from storage.org_git_claim_store import OrgGitClaimStore
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
async def resolve_org_for_repo(
|
||||
provider: str,
|
||||
full_repo_name: str,
|
||||
keycloak_user_id: str | None = None,
|
||||
) -> UUID | None:
|
||||
"""Determine the OpenHands org_id for a resolver conversation.
|
||||
|
||||
If the repo's git organization is claimed by an OpenHands org, returns the
|
||||
claiming org's ID. When keycloak_user_id is provided, also verifies the user
|
||||
is a member of that org.
|
||||
|
||||
Args:
|
||||
provider: Git provider name ("github", "gitlab", "bitbucket")
|
||||
full_repo_name: Full repository name (e.g., "OpenHands/foo")
|
||||
keycloak_user_id: The user's Keycloak UUID string (optional). If provided,
|
||||
membership is verified before returning the org_id.
|
||||
|
||||
Returns:
|
||||
The org_id if the repo's org is claimed (and user is a member when
|
||||
keycloak_user_id is provided), else None
|
||||
"""
|
||||
git_org = full_repo_name.split('/')[0].lower()
|
||||
|
||||
try:
|
||||
claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
|
||||
provider, git_org
|
||||
)
|
||||
if not claim:
|
||||
logger.debug(
|
||||
f'[OrgResolver] No claim found for {provider}/{git_org}',
|
||||
)
|
||||
return None
|
||||
|
||||
# Skip membership check if no user_id provided
|
||||
if keycloak_user_id is None:
|
||||
logger.info(
|
||||
f'[OrgResolver] Resolved org {claim.org_id} '
|
||||
f'for {provider}/{git_org} (no user membership check)',
|
||||
)
|
||||
return claim.org_id
|
||||
|
||||
member = await OrgMemberStore.get_org_member(
|
||||
claim.org_id, UUID(keycloak_user_id)
|
||||
)
|
||||
if not member:
|
||||
logger.debug(
|
||||
f'[OrgResolver] User {keycloak_user_id} is not a member of org '
|
||||
f'{claim.org_id} (claimed {provider}/{git_org}). '
|
||||
f'Falling back to personal workspace.',
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f'[OrgResolver] Routing conversation to org {claim.org_id} '
|
||||
f'for {provider}/{git_org} (user {keycloak_user_id})',
|
||||
)
|
||||
return claim.org_id
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[OrgResolver] Error resolving org for {provider}/{git_org}: {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
@@ -26,7 +26,6 @@ class SlackErrorCode(Enum):
|
||||
PROVIDER_AUTH_FAILED = 'SLACK_ERR_006'
|
||||
LLM_AUTH_FAILED = 'SLACK_ERR_007'
|
||||
MISSING_SETTINGS = 'SLACK_ERR_008'
|
||||
MISSING_SLACK_SCOPES = 'SLACK_ERR_009'
|
||||
UNEXPECTED_ERROR = 'SLACK_ERR_999'
|
||||
|
||||
|
||||
@@ -99,11 +98,6 @@ _USER_MESSAGES: dict[SlackErrorCode, str] = {
|
||||
'{username} please re-login into '
|
||||
f'[OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
),
|
||||
SlackErrorCode.MISSING_SLACK_SCOPES: (
|
||||
'⚠️ The Slack app is missing required permissions. '
|
||||
f'Please ask your workspace admin to re-install the OpenHands Slack App at {HOST_URL}/slack/install '
|
||||
'to authorize the updated permissions.'
|
||||
),
|
||||
SlackErrorCode.UNEXPECTED_ERROR: (
|
||||
'Uh oh! There was an unexpected error (ref: {code}). Please try again later.'
|
||||
),
|
||||
|
||||
@@ -24,26 +24,27 @@ from integrations.utils import (
|
||||
from integrations.v1_utils import get_saas_user_auth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
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.slack_user import SlackUser
|
||||
|
||||
from openhands.app_server.integrations.provider import ProviderHandler
|
||||
from openhands.app_server.integrations.service_types import (
|
||||
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.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import config, server_config, sio
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
authorize_url_generator = AuthorizeUrlGenerator(
|
||||
client_id=SLACK_CLIENT_ID,
|
||||
@@ -238,14 +239,12 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
def _generate_repo_selection_form(
|
||||
self, message_ts: str, thread_ts: str | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Generate a repo selection form with immediate "No Repository" button and search dropdown.
|
||||
"""Generate a repo selection form using external_select for dynamic loading.
|
||||
|
||||
This form provides two options side-by-side:
|
||||
1. A "No Repository" button - immediately clickable without any loading
|
||||
2. An external_select dropdown - for searching repositories dynamically
|
||||
|
||||
This design ensures "No Repository" is always immediately available while
|
||||
still providing full dynamic search capability for repositories.
|
||||
This uses Slack's external_select element which allows:
|
||||
- Type-ahead search for repositories
|
||||
- Dynamic loading of options from an external endpoint
|
||||
- Support for users with many repositories (no 100 option limit)
|
||||
|
||||
Args:
|
||||
message_ts: The message timestamp for tracking
|
||||
@@ -267,22 +266,12 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
'type': 'section',
|
||||
'text': {
|
||||
'type': 'mrkdwn',
|
||||
'text': 'Select a repository or continue without one:',
|
||||
'text': 'Type to search your repositories:',
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'actions',
|
||||
'elements': [
|
||||
{
|
||||
'type': 'button',
|
||||
'action_id': f'no_repository:{message_ts}:{thread_ts}',
|
||||
'text': {
|
||||
'type': 'plain_text',
|
||||
'text': 'No Repository',
|
||||
'emoji': True,
|
||||
},
|
||||
'value': '-',
|
||||
},
|
||||
{
|
||||
'type': 'external_select',
|
||||
'action_id': f'repository_select:{message_ts}:{thread_ts}',
|
||||
@@ -290,8 +279,8 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
'type': 'plain_text',
|
||||
'text': 'Search repositories...',
|
||||
},
|
||||
'min_query_length': 0,
|
||||
},
|
||||
'min_query_length': 0, # Load initial options immediately
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -299,11 +288,8 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
def _build_repo_options(self, repos: list[Repository]) -> list[dict[str, Any]]:
|
||||
"""Build Slack options list from repositories.
|
||||
|
||||
Returns up to 100 repositories formatted as Slack options
|
||||
(Slack has a 100 option limit for external_select).
|
||||
|
||||
Note: "No Repository" is handled by a separate button in the form,
|
||||
so it's not included in the dropdown options.
|
||||
Always includes a "No Repository" option at the top, followed by up to 99
|
||||
repositories (Slack has a 100 option limit for external_select).
|
||||
|
||||
Args:
|
||||
repos: List of Repository objects
|
||||
@@ -311,7 +297,13 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
Returns:
|
||||
List of Slack option objects
|
||||
"""
|
||||
return [
|
||||
options: list[dict[str, Any]] = [
|
||||
{
|
||||
'text': {'type': 'plain_text', 'text': 'No Repository'},
|
||||
'value': '-',
|
||||
}
|
||||
]
|
||||
options.extend(
|
||||
{
|
||||
'text': {
|
||||
'type': 'plain_text',
|
||||
@@ -319,8 +311,9 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
},
|
||||
'value': repo.full_name,
|
||||
}
|
||||
for repo in repos[:100]
|
||||
]
|
||||
for repo in repos[:99] # Leave room for "No Repository" option
|
||||
)
|
||||
return options
|
||||
|
||||
async def search_repos_for_slack(
|
||||
self, user_auth: UserAuth, query: str, per_page: int = 20
|
||||
@@ -370,69 +363,33 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
SlackError(SlackErrorCode.UNEXPECTED_ERROR),
|
||||
)
|
||||
|
||||
def _parse_form_action(self, action: dict) -> tuple[str, str | None, str] | None:
|
||||
"""Parse action payload and extract message_ts, thread_ts, and selected value.
|
||||
|
||||
This handles the different payload structures for button clicks vs dropdown
|
||||
selections in the repository selection form.
|
||||
|
||||
Args:
|
||||
action: The action object from the Slack payload
|
||||
|
||||
Returns:
|
||||
Tuple of (message_ts, thread_ts, selected_value) if action is recognized,
|
||||
None if the action_id is unknown.
|
||||
"""
|
||||
action_id = action['action_id']
|
||||
|
||||
if action_id.startswith('no_repository:'):
|
||||
# Button click - value is in 'value' field
|
||||
attribs = action_id.split('no_repository:')[-1]
|
||||
selected_value = action.get('value', '-')
|
||||
elif action_id.startswith('repository_select:'):
|
||||
# Dropdown selection - value is in 'selected_option'
|
||||
attribs = action_id.split('repository_select:')[-1]
|
||||
selected_value = action['selected_option']['value']
|
||||
else:
|
||||
return None
|
||||
|
||||
message_ts, thread_ts = attribs.split(':')
|
||||
thread_ts = None if thread_ts == 'None' else thread_ts
|
||||
|
||||
return message_ts, thread_ts, selected_value
|
||||
|
||||
async def receive_form_interaction(self, slack_payload: dict):
|
||||
"""Process a Slack form interaction (repository selection or button click).
|
||||
"""Process a Slack form interaction (repository selection).
|
||||
|
||||
This handles the block_actions payload when a user interacts with the
|
||||
repository selection form. It can handle:
|
||||
- "No Repository" button click: proceeds with conversation without a repo
|
||||
- Repository selection from dropdown: proceeds with the selected repo
|
||||
This handles the block_actions payload when a user selects a repository
|
||||
from the dropdown form. It retrieves the original user message from Redis
|
||||
and delegates to receive_message for processing.
|
||||
|
||||
Args:
|
||||
slack_payload: The raw Slack interaction payload
|
||||
"""
|
||||
# Extract fields from the Slack interaction payload
|
||||
action = slack_payload['actions'][0]
|
||||
selected_repository = slack_payload['actions'][0]['selected_option']['value']
|
||||
if selected_repository == '-':
|
||||
selected_repository = None
|
||||
|
||||
slack_user_id = slack_payload['user']['id']
|
||||
channel_id = slack_payload['container']['channel_id']
|
||||
team_id = slack_payload['team']['id']
|
||||
|
||||
# Parse the action to extract message_ts, thread_ts, and selected value
|
||||
parsed = self._parse_form_action(action)
|
||||
if parsed is None:
|
||||
logger.warning(
|
||||
'slack_unknown_action_id',
|
||||
extra={
|
||||
'action_id': action['action_id'],
|
||||
'slack_user_id': slack_user_id,
|
||||
},
|
||||
)
|
||||
return
|
||||
# Get original message_ts and thread_ts from action_id
|
||||
attribs = slack_payload['actions'][0]['action_id'].split('repository_select:')[
|
||||
-1
|
||||
]
|
||||
message_ts, thread_ts = attribs.split(':')
|
||||
thread_ts = None if thread_ts == 'None' else thread_ts
|
||||
|
||||
message_ts, thread_ts, selected_value = parsed
|
||||
|
||||
# Build partial payload for error handling
|
||||
# Build partial payload for error handling during Redis retrieval
|
||||
payload = {
|
||||
'team_id': team_id,
|
||||
'channel_id': channel_id,
|
||||
@@ -441,9 +398,6 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
'thread_ts': thread_ts,
|
||||
}
|
||||
|
||||
# Convert "-" (No Repository) to None
|
||||
selected_repository = None if selected_value == '-' else selected_value
|
||||
|
||||
# Retrieve the original user message from Redis
|
||||
try:
|
||||
user_msg = await self._retrieve_user_msg_for_form(message_ts, thread_ts)
|
||||
@@ -697,7 +651,11 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
return False
|
||||
|
||||
async def start_job(self, slack_view: SlackViewInterface) -> None:
|
||||
"""Start a Slack job using V1 app conversation system."""
|
||||
# Importing here prevents circular import
|
||||
from server.conversation_callback_processor.slack_callback_processor import (
|
||||
SlackCallbackProcessor,
|
||||
)
|
||||
|
||||
try:
|
||||
msg_info = None
|
||||
user_info = slack_view.slack_to_openhands_user
|
||||
@@ -714,7 +672,37 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
f'[Slack] Created conversation {conversation_id} for user {user_info.slack_display_name}'
|
||||
)
|
||||
|
||||
# V1 callback processors are registered by the view during conversation creation
|
||||
# Only add SlackCallbackProcessor for new conversations (not updates) and non-v1 conversations
|
||||
if (
|
||||
not isinstance(slack_view, SlackUpdateExistingConversationView)
|
||||
and not slack_view.v1_enabled
|
||||
):
|
||||
# We don't re-subscribe for follow up messages from slack.
|
||||
# Summaries are generated for every messages anyways, we only need to do
|
||||
# this subscription once for the event which kicked off the job.
|
||||
|
||||
processor = SlackCallbackProcessor(
|
||||
slack_user_id=slack_view.slack_user_id,
|
||||
channel_id=slack_view.channel_id,
|
||||
message_ts=slack_view.message_ts,
|
||||
thread_ts=slack_view.thread_ts,
|
||||
team_id=slack_view.team_id,
|
||||
)
|
||||
|
||||
# Register the callback processor
|
||||
register_callback_processor(conversation_id, processor)
|
||||
|
||||
logger.info(
|
||||
f'[Slack] Created callback processor for conversation {conversation_id}'
|
||||
)
|
||||
elif isinstance(slack_view, SlackUpdateExistingConversationView):
|
||||
logger.info(
|
||||
f'[Slack] Skipping callback processor for existing conversation update {conversation_id}'
|
||||
)
|
||||
elif slack_view.v1_enabled:
|
||||
logger.info(
|
||||
f'[Slack] Skipping callback processor for v1 conversation {conversation_id}'
|
||||
)
|
||||
|
||||
msg_info = slack_view.get_response_msg()
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from integrations.types import SummaryExtractionTracker
|
||||
from jinja2 import Environment
|
||||
from storage.slack_user import SlackUser
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -112,6 +112,7 @@ class SlackViewInterface(SlackMessageView, SummaryExtractionTracker, ABC):
|
||||
should_extract: bool
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
@abstractmethod
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
|
||||
@@ -40,20 +40,16 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for Slack V1 integration."""
|
||||
# Only handle ConversationStateUpdateEvent for execution_status
|
||||
# Only handle ConversationStateUpdateEvent
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
|
||||
if event.key != 'execution_status':
|
||||
# Only act when execution has finished
|
||||
if not (event.key == 'execution_status' and event.value == 'finished'):
|
||||
return None
|
||||
|
||||
# Log ALL terminal states for monitoring (finished, error, stuck)
|
||||
_logger.info('[Slack V1] Callback agent state was %s', event)
|
||||
|
||||
# Only request summary when execution has finished successfully
|
||||
if event.value != 'finished':
|
||||
return None
|
||||
|
||||
try:
|
||||
summary = await self._request_summary(conversation_id)
|
||||
await self._post_summary_to_slack(summary)
|
||||
@@ -111,11 +107,9 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
|
||||
|
||||
try:
|
||||
# Post the summary as a threaded reply
|
||||
# Use markdown_text instead of text to properly render standard Markdown
|
||||
# (e.g., **bold**, [link](url)) which is used throughout the codebase
|
||||
response = client.chat_postMessage(
|
||||
channel=channel_id,
|
||||
markdown_text=summary,
|
||||
text=summary,
|
||||
thread_ts=thread_ts,
|
||||
unfurl_links=False,
|
||||
unfurl_media=False,
|
||||
|
||||
@@ -4,8 +4,6 @@ from uuid import UUID, uuid4
|
||||
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.slack.slack_errors import SlackError, SlackErrorCode
|
||||
from integrations.slack.slack_types import (
|
||||
SlackMessageView,
|
||||
SlackViewInterface,
|
||||
@@ -14,10 +12,12 @@ from integrations.slack.slack_types import (
|
||||
from integrations.slack.slack_v1_callback_processor import SlackV1CallbackProcessor
|
||||
from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
ENABLE_V1_SLACK_RESOLVER,
|
||||
get_final_agent_observation,
|
||||
get_user_v1_enabled_setting,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
from storage.slack_conversation import SlackConversation
|
||||
from storage.slack_conversation_store import SlackConversationStore
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
@@ -26,17 +26,27 @@ from storage.slack_user import SlackUser
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
SendMessageRequest,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.integrations.provider import ProviderHandler
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_conversation_settings,
|
||||
)
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT
|
||||
|
||||
# =================================================
|
||||
@@ -49,6 +59,10 @@ slack_conversation_store = SlackConversationStore.get_instance()
|
||||
slack_team_store = SlackTeamStore.get_instance()
|
||||
|
||||
|
||||
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 SlackNewConversationView(SlackViewInterface):
|
||||
bot_access_token: str
|
||||
@@ -64,6 +78,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
team_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_initial_prompt(self, text: str, blocks: list[dict]):
|
||||
bot_id = self._get_bot_id(blocks)
|
||||
@@ -88,34 +103,24 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
messages = []
|
||||
if self.thread_ts:
|
||||
client = WebClient(token=self.bot_access_token)
|
||||
try:
|
||||
result = client.conversations_replies(
|
||||
channel=self.channel_id,
|
||||
ts=self.thread_ts,
|
||||
inclusive=True,
|
||||
latest=self.message_ts,
|
||||
limit=CONTEXT_LIMIT, # We can be smarter about getting more context/condensing it even in the future
|
||||
)
|
||||
except SlackApiError as e:
|
||||
if e.response.get('error') == 'missing_scope':
|
||||
raise SlackError(SlackErrorCode.MISSING_SLACK_SCOPES) from e
|
||||
raise
|
||||
result = client.conversations_replies(
|
||||
channel=self.channel_id,
|
||||
ts=self.thread_ts,
|
||||
inclusive=True,
|
||||
latest=self.message_ts,
|
||||
limit=CONTEXT_LIMIT, # We can be smarter about getting more context/condensing it even in the future
|
||||
)
|
||||
|
||||
messages = result['messages']
|
||||
|
||||
else:
|
||||
client = WebClient(token=self.bot_access_token)
|
||||
try:
|
||||
result = client.conversations_history(
|
||||
channel=self.channel_id,
|
||||
inclusive=True,
|
||||
latest=self.message_ts,
|
||||
limit=CONTEXT_LIMIT,
|
||||
)
|
||||
except SlackApiError as e:
|
||||
if e.response.get('error') == 'missing_scope':
|
||||
raise SlackError(SlackErrorCode.MISSING_SLACK_SCOPES) from e
|
||||
raise
|
||||
result = client.conversations_history(
|
||||
channel=self.channel_id,
|
||||
inclusive=True,
|
||||
latest=self.message_ts,
|
||||
limit=CONTEXT_LIMIT,
|
||||
)
|
||||
|
||||
messages = result['messages']
|
||||
messages.reverse()
|
||||
@@ -152,7 +157,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
'Attempting to start conversation without confirming selected repo from user'
|
||||
)
|
||||
|
||||
async def save_slack_convo(self):
|
||||
async def save_slack_convo(self, v1_enabled: bool = False):
|
||||
if self.slack_to_openhands_user:
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
|
||||
@@ -164,6 +169,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
'keycloak_user_id': user_info.keycloak_user_id,
|
||||
'org_id': user_info.org_id,
|
||||
'parent_id': self.thread_ts or self.message_ts,
|
||||
'v1_enabled': v1_enabled,
|
||||
},
|
||||
)
|
||||
slack_conversation = SlackConversation(
|
||||
@@ -173,7 +179,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
org_id=user_info.org_id,
|
||||
parent_id=self.thread_ts
|
||||
or self.message_ts, # conversations can start in a thread reply as well; we should always references the parent's (root level msg's) message ID
|
||||
v1_enabled=True, # All conversations are V1
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
await slack_conversation_store.create_slack_conversation(slack_conversation)
|
||||
|
||||
@@ -194,26 +200,56 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
self._verify_necessary_values_are_set()
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
user_secrets = await self.saas_user_auth.get_secrets()
|
||||
|
||||
# Determine git provider from repository (needed for both org routing and conversation creation)
|
||||
self._resolved_git_provider = None
|
||||
# Check if V1 conversations are enabled for this user
|
||||
self.v1_enabled = await is_v1_enabled_for_slack_resolver(
|
||||
self.slack_to_openhands_user.keycloak_user_id
|
||||
)
|
||||
|
||||
if self.v1_enabled:
|
||||
# Use V1 app conversation service
|
||||
await self._create_v1_conversation(jinja)
|
||||
return self.conversation_id
|
||||
else:
|
||||
# Use existing V0 conversation service
|
||||
await self._create_v0_conversation(jinja, provider_tokens, user_secrets)
|
||||
return self.conversation_id
|
||||
|
||||
async def _create_v0_conversation(
|
||||
self, jinja: Environment, provider_tokens, user_secrets
|
||||
) -> None:
|
||||
"""Create conversation using the legacy V0 system."""
|
||||
user_instructions, conversation_instructions = await self._get_instructions(
|
||||
jinja
|
||||
)
|
||||
|
||||
# Determine git provider from repository
|
||||
git_provider = None
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
self._resolved_git_provider = repository.git_provider
|
||||
git_provider = repository.git_provider
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = None
|
||||
if self._resolved_git_provider and self.selected_repo:
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider=self._resolved_git_provider.value,
|
||||
full_repo_name=self.selected_repo,
|
||||
keycloak_user_id=self.slack_to_openhands_user.keycloak_user_id,
|
||||
)
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=self.slack_to_openhands_user.keycloak_user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
initial_user_msg=user_instructions,
|
||||
conversation_instructions=(
|
||||
conversation_instructions if conversation_instructions else None
|
||||
),
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_trigger=ConversationTrigger.SLACK,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
git_provider=git_provider,
|
||||
)
|
||||
|
||||
# V0 conversation path has been removed - all conversations use V1 app conversation service
|
||||
await self._create_v1_conversation(jinja)
|
||||
return self.conversation_id
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
|
||||
await self.save_slack_convo(v1_enabled=False)
|
||||
|
||||
async def _create_v1_conversation(self, jinja: Environment) -> None:
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
@@ -229,8 +265,13 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
# Create the Slack V1 callback processor
|
||||
slack_callback_processor = self._create_slack_v1_callback_processor()
|
||||
|
||||
# Use git provider resolved in create_or_update_conversation
|
||||
git_provider = self._resolved_git_provider
|
||||
# Determine git provider from repository
|
||||
git_provider = None
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
git_provider = ProviderType(repository.git_provider.value)
|
||||
|
||||
# Get the app conversation service and start the conversation
|
||||
injector_state = InjectorState()
|
||||
@@ -251,10 +292,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
)
|
||||
|
||||
# Set up the Slack user context for the V1 system
|
||||
slack_user_context = ResolverUserContext(
|
||||
saas_user_auth=self.saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
@@ -270,7 +308,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
)
|
||||
|
||||
logger.info(f'[Slack V1]: Created new conversation: {self.conversation_id}')
|
||||
await self.save_slack_convo()
|
||||
await self.save_slack_convo(v1_enabled=True)
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
@@ -292,18 +330,13 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
client = WebClient(token=self.bot_access_token)
|
||||
try:
|
||||
result = client.conversations_replies(
|
||||
channel=self.channel_id,
|
||||
ts=self.message_ts,
|
||||
inclusive=True,
|
||||
latest=self.message_ts,
|
||||
limit=1, # Get exact user message, in future we can be smarter with collecting additional context
|
||||
)
|
||||
except SlackApiError as e:
|
||||
if e.response.get('error') == 'missing_scope':
|
||||
raise SlackError(SlackErrorCode.MISSING_SLACK_SCOPES) from e
|
||||
raise
|
||||
result = client.conversations_replies(
|
||||
channel=self.channel_id,
|
||||
ts=self.message_ts,
|
||||
inclusive=True,
|
||||
latest=self.message_ts,
|
||||
limit=1, # Get exact user message, in future we can be smarter with collecting additional context
|
||||
)
|
||||
|
||||
user_message = result['messages'][0]
|
||||
user_message = self._get_initial_prompt(
|
||||
@@ -312,6 +345,53 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
|
||||
return user_message, ''
|
||||
|
||||
async def send_message_to_v0_conversation(self, jinja: Environment):
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
user_id = user_info.keycloak_user_id
|
||||
saas_user_auth: UserAuth = self.saas_user_auth
|
||||
provider_tokens = await saas_user_auth.get_provider_tokens()
|
||||
|
||||
try:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
# Should we raise here if there are no provider tokens?
|
||||
providers_set = list(provider_tokens.keys()) if provider_tokens else []
|
||||
|
||||
conversation_init_data = await setup_init_conversation_settings(
|
||||
user_id, self.conversation_id, providers_set
|
||||
)
|
||||
|
||||
# Either join ongoing conversation, or restart the conversation
|
||||
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
|
||||
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
|
||||
)
|
||||
agent_state = (
|
||||
None
|
||||
if len(final_agent_observation) == 0
|
||||
else final_agent_observation[0].agent_state
|
||||
)
|
||||
|
||||
if not agent_state or agent_state == AgentState.LOADING:
|
||||
raise StartingConvoException('Conversation is still starting')
|
||||
|
||||
instructions, _ = await self._get_instructions(jinja)
|
||||
user_msg = MessageAction(content=instructions)
|
||||
await conversation_manager.send_event_to_conversation(
|
||||
self.conversation_id, event_to_dict(user_msg)
|
||||
)
|
||||
|
||||
async def send_message_to_v1_conversation(self, jinja: Environment):
|
||||
"""Send a message to a v1 conversation using the agent server API."""
|
||||
# Import services within the method to avoid circular imports
|
||||
@@ -382,7 +462,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()
|
||||
@@ -406,7 +486,7 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
raise Exception(f'Failed to send message to v1 conversation: {str(e)}')
|
||||
|
||||
async def create_or_update_conversation(self, jinja: Environment) -> str:
|
||||
"""Send new user message to conversation."""
|
||||
"""Send new user message to converation"""
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
|
||||
user_id = user_info.keycloak_user_id
|
||||
@@ -418,8 +498,10 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
|
||||
)
|
||||
|
||||
# All conversations use V1 app conversation system
|
||||
await self.send_message_to_v1_conversation(jinja)
|
||||
if self.slack_conversation.v1_enabled:
|
||||
await self.send_message_to_v1_conversation(jinja)
|
||||
else:
|
||||
await self.send_message_to_v0_conversation(jinja)
|
||||
|
||||
return self.conversation_id
|
||||
|
||||
@@ -523,6 +605,7 @@ class SlackFactory:
|
||||
conversation_id=conversation.conversation_id,
|
||||
slack_conversation=conversation,
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif SlackFactory.did_user_select_repo_from_form(message):
|
||||
@@ -540,6 +623,7 @@ class SlackFactory:
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -557,6 +641,7 @@ class SlackFactory:
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
41
enterprise/integrations/solvability/data/__init__.py
Normal file
41
enterprise/integrations/solvability/data/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Utilities for loading and managing pre-trained classifiers.
|
||||
|
||||
Assumes that classifiers are stored adjacent to this file in the `solvability/data` directory, using a simple
|
||||
`name + .json` pattern.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from integrations.solvability.models.classifier import SolvabilityClassifier
|
||||
|
||||
|
||||
def load_classifier(name: str) -> SolvabilityClassifier:
|
||||
"""
|
||||
Load a classifier by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the classifier to load.
|
||||
|
||||
Returns:
|
||||
SolvabilityClassifier: The loaded classifier instance.
|
||||
"""
|
||||
data_dir = Path(__file__).parent
|
||||
classifier_path = data_dir / f'{name}.json'
|
||||
|
||||
if not classifier_path.exists():
|
||||
raise FileNotFoundError(f"Classifier '{name}' not found at {classifier_path}")
|
||||
|
||||
with classifier_path.open('r') as f:
|
||||
return SolvabilityClassifier.model_validate_json(f.read())
|
||||
|
||||
|
||||
def available_classifiers() -> list[str]:
|
||||
"""
|
||||
List all available classifiers in the data directory.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of classifier names (without the .json extension).
|
||||
"""
|
||||
data_dir = Path(__file__).parent
|
||||
return [f.stem for f in data_dir.glob('*.json') if f.is_file()]
|
||||
File diff suppressed because one or more lines are too long
38
enterprise/integrations/solvability/models/__init__.py
Normal file
38
enterprise/integrations/solvability/models/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Solvability Models Package
|
||||
|
||||
This package contains the core machine learning models and components for predicting
|
||||
the solvability of GitHub issues and similar technical problems.
|
||||
|
||||
The solvability prediction system works by:
|
||||
1. Using a Featurizer to extract semantic features from issue descriptions via LLM calls
|
||||
2. Training a RandomForestClassifier on these features to predict solvability
|
||||
3. Generating detailed reports with feature importance analysis
|
||||
|
||||
Key Components:
|
||||
- Feature: Defines individual features that can be extracted from issues
|
||||
- Featurizer: Orchestrates LLM-based feature extraction with sampling and batching
|
||||
- SolvabilityClassifier: Main ML pipeline combining featurization and classification
|
||||
- SolvabilityReport: Comprehensive output with predictions, feature analysis, and metadata
|
||||
- ImportanceStrategy: Configurable methods for calculating feature importance (SHAP, permutation, impurity)
|
||||
"""
|
||||
|
||||
from integrations.solvability.models.classifier import SolvabilityClassifier
|
||||
from integrations.solvability.models.featurizer import (
|
||||
EmbeddingDimension,
|
||||
Feature,
|
||||
FeatureEmbedding,
|
||||
Featurizer,
|
||||
)
|
||||
from integrations.solvability.models.importance_strategy import ImportanceStrategy
|
||||
from integrations.solvability.models.report import SolvabilityReport
|
||||
|
||||
__all__ = [
|
||||
'Feature',
|
||||
'EmbeddingDimension',
|
||||
'FeatureEmbedding',
|
||||
'Featurizer',
|
||||
'ImportanceStrategy',
|
||||
'SolvabilityClassifier',
|
||||
'SolvabilityReport',
|
||||
]
|
||||
433
enterprise/integrations/solvability/models/classifier.py
Normal file
433
enterprise/integrations/solvability/models/classifier.py
Normal file
@@ -0,0 +1,433 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import pickle
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import shap
|
||||
from integrations.solvability.models.featurizer import Feature, Featurizer
|
||||
from integrations.solvability.models.importance_strategy import ImportanceStrategy
|
||||
from integrations.solvability.models.report import SolvabilityReport
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
PrivateAttr,
|
||||
field_serializer,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from sklearn.ensemble import RandomForestClassifier
|
||||
from sklearn.exceptions import NotFittedError
|
||||
from sklearn.inspection import permutation_importance
|
||||
from sklearn.utils.validation import check_is_fitted
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
|
||||
|
||||
class SolvabilityClassifier(BaseModel):
|
||||
"""
|
||||
Machine learning pipeline for predicting the solvability of GitHub issues and similar problems.
|
||||
|
||||
This classifier combines LLM-based feature extraction with traditional ML classification:
|
||||
1. Uses a Featurizer to extract semantic boolean features from issue descriptions via LLM calls
|
||||
2. Trains a RandomForestClassifier on these features to predict solvability scores
|
||||
3. Provides feature importance analysis using configurable strategies (SHAP, permutation, impurity)
|
||||
4. Generates comprehensive reports with predictions, feature analysis, and cost metrics
|
||||
|
||||
The classifier supports both training on labeled data and inference on new issues, with built-in
|
||||
support for batch processing and concurrent feature extraction.
|
||||
"""
|
||||
|
||||
identifier: str
|
||||
"""
|
||||
The identifier for the classifier.
|
||||
"""
|
||||
|
||||
featurizer: Featurizer
|
||||
"""
|
||||
The featurizer to use for transforming the input data.
|
||||
"""
|
||||
|
||||
classifier: RandomForestClassifier
|
||||
"""
|
||||
The RandomForestClassifier used for predicting solvability from extracted features.
|
||||
|
||||
This ensemble model provides robust predictions and built-in feature importance metrics.
|
||||
"""
|
||||
|
||||
importance_strategy: ImportanceStrategy = ImportanceStrategy.IMPURITY
|
||||
"""
|
||||
Strategy to use for calculating feature importance.
|
||||
"""
|
||||
|
||||
samples: int = 10
|
||||
"""
|
||||
Number of samples to use for calculating feature embedding coefficients.
|
||||
"""
|
||||
|
||||
random_state: int | None = None
|
||||
"""
|
||||
Random state for reproducibility.
|
||||
"""
|
||||
|
||||
_classifier_attrs: dict[str, Any] = PrivateAttr(default_factory=dict)
|
||||
"""
|
||||
Private dictionary storing cached results from feature extraction and importance calculations.
|
||||
|
||||
Contains keys like 'features_', 'cost_', 'feature_importances_', and 'labels_' that are populated
|
||||
during transform(), fit(), and predict() operations. Access these via the corresponding properties.
|
||||
|
||||
This field is never serialized, so cached values will not persist across model save/load cycles.
|
||||
"""
|
||||
|
||||
model_config = {
|
||||
'arbitrary_types_allowed': True,
|
||||
}
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_random_state(self) -> SolvabilityClassifier:
|
||||
"""
|
||||
Validate the random state configuration between this object and the classifier.
|
||||
"""
|
||||
# If both random states are set, they definitely need to agree.
|
||||
if self.random_state is not None and self.classifier.random_state is not None:
|
||||
if self.random_state != self.classifier.random_state:
|
||||
raise ValueError(
|
||||
'The random state of the classifier and the top-level classifier must agree.'
|
||||
)
|
||||
|
||||
# Otherwise, we'll always set the classifier's random state to the top-level one.
|
||||
self.classifier.random_state = self.random_state
|
||||
|
||||
return self
|
||||
|
||||
@property
|
||||
def features_(self) -> pd.DataFrame:
|
||||
"""
|
||||
Get the features used by the classifier for the most recent inputs.
|
||||
"""
|
||||
if 'features_' not in self._classifier_attrs:
|
||||
raise ValueError(
|
||||
'SolvabilityClassifier.transform() has not yet been called.'
|
||||
)
|
||||
return self._classifier_attrs['features_']
|
||||
|
||||
@property
|
||||
def cost_(self) -> pd.DataFrame:
|
||||
"""
|
||||
Get the cost of the classifier for the most recent inputs.
|
||||
"""
|
||||
if 'cost_' not in self._classifier_attrs:
|
||||
raise ValueError(
|
||||
'SolvabilityClassifier.transform() has not yet been called.'
|
||||
)
|
||||
return self._classifier_attrs['cost_']
|
||||
|
||||
@property
|
||||
def feature_importances_(self) -> np.ndarray:
|
||||
"""
|
||||
Get the feature importances for the most recent inputs.
|
||||
"""
|
||||
if 'feature_importances_' not in self._classifier_attrs:
|
||||
raise ValueError(
|
||||
'No SolvabilityClassifier methods that produce feature importances (.fit(), .predict_proba(), and '
|
||||
'.predict()) have been called.'
|
||||
)
|
||||
return self._classifier_attrs['feature_importances_'] # type: ignore[no-any-return]
|
||||
|
||||
@property
|
||||
def is_fitted(self) -> bool:
|
||||
"""
|
||||
Check if the classifier is fitted.
|
||||
"""
|
||||
try:
|
||||
check_is_fitted(self.classifier)
|
||||
return True
|
||||
except NotFittedError:
|
||||
return False
|
||||
|
||||
def transform(self, issues: pd.Series, llm_config: LLMConfig) -> pd.DataFrame:
|
||||
"""
|
||||
Transform the input issues using the featurizer to extract features.
|
||||
|
||||
This method orchestrates the feature extraction pipeline:
|
||||
1. Uses the featurizer to generate embeddings for all issues
|
||||
2. Converts embeddings to a structured DataFrame
|
||||
3. Separates feature columns from metadata columns
|
||||
4. Stores results for later access via properties
|
||||
|
||||
Args:
|
||||
issues: A pandas Series containing the issue descriptions.
|
||||
llm_config: LLM configuration to use for feature extraction.
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: A DataFrame containing only the feature columns (no metadata).
|
||||
"""
|
||||
# Generate feature embeddings for all issues using batch processing
|
||||
feature_embeddings = self.featurizer.embed_batch(
|
||||
issues, samples=self.samples, llm_config=llm_config
|
||||
)
|
||||
df = pd.DataFrame(embedding.to_row() for embedding in feature_embeddings)
|
||||
|
||||
# Split into feature columns (used by classifier) and cost columns (metadata)
|
||||
feature_columns = [feature.identifier for feature in self.featurizer.features]
|
||||
cost_columns = [col for col in df.columns if col not in feature_columns]
|
||||
|
||||
# Store both sets for access via properties
|
||||
self._classifier_attrs['features_'] = df[feature_columns]
|
||||
self._classifier_attrs['cost_'] = df[cost_columns]
|
||||
|
||||
return self.features_
|
||||
|
||||
def fit(
|
||||
self, issues: pd.Series, labels: pd.Series, llm_config: LLMConfig
|
||||
) -> SolvabilityClassifier:
|
||||
"""
|
||||
Fit the classifier to the input issues and labels.
|
||||
|
||||
Args:
|
||||
issues: A pandas Series containing the issue descriptions.
|
||||
|
||||
labels: A pandas Series containing the labels (0 or 1) for each issue.
|
||||
|
||||
llm_config: LLM configuration to use for feature extraction.
|
||||
|
||||
Returns:
|
||||
SolvabilityClassifier: The fitted classifier.
|
||||
"""
|
||||
features = self.transform(issues, llm_config=llm_config)
|
||||
self.classifier.fit(features, labels)
|
||||
|
||||
# Store labels for permutation importance calculation
|
||||
self._classifier_attrs['labels_'] = labels
|
||||
self._classifier_attrs['feature_importances_'] = self._importance(
|
||||
features, self.classifier.predict_proba(features), labels
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
def predict_proba(self, issues: pd.Series, llm_config: LLMConfig) -> np.ndarray:
|
||||
"""
|
||||
Predict the solvability probabilities for the input issues.
|
||||
|
||||
Returns class probabilities where the second column represents the probability
|
||||
of the issue being solvable (positive class).
|
||||
|
||||
Args:
|
||||
issues: A pandas Series containing the issue descriptions.
|
||||
llm_config: LLM configuration to use for feature extraction.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Array of shape (n_samples, 2) with probabilities for each class.
|
||||
Column 0: probability of not solvable, Column 1: probability of solvable.
|
||||
"""
|
||||
features = self.transform(issues, llm_config=llm_config)
|
||||
scores = self.classifier.predict_proba(features)
|
||||
|
||||
# Calculate feature importances based on the configured strategy
|
||||
# For permutation importance, we need ground truth labels if available
|
||||
labels = self._classifier_attrs.get('labels_')
|
||||
if (
|
||||
self.importance_strategy == ImportanceStrategy.PERMUTATION
|
||||
and labels is not None
|
||||
):
|
||||
self._classifier_attrs['feature_importances_'] = self._importance(
|
||||
features, scores, labels
|
||||
)
|
||||
else:
|
||||
self._classifier_attrs['feature_importances_'] = self._importance(
|
||||
features, scores
|
||||
)
|
||||
|
||||
return scores # type: ignore[no-any-return]
|
||||
|
||||
def predict(self, issues: pd.Series, llm_config: LLMConfig) -> np.ndarray:
|
||||
"""
|
||||
Predict the solvability of the input issues by returning binary labels.
|
||||
|
||||
Uses a 0.5 probability threshold to convert probabilities to binary predictions.
|
||||
|
||||
Args:
|
||||
issues: A pandas Series containing the issue descriptions.
|
||||
llm_config: LLM configuration to use for feature extraction.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Boolean array where True indicates the issue is predicted as solvable.
|
||||
"""
|
||||
probabilities = self.predict_proba(issues, llm_config=llm_config)
|
||||
# Apply 0.5 threshold to convert probabilities to binary predictions
|
||||
labels = probabilities[:, 1] >= 0.5
|
||||
return labels
|
||||
|
||||
def _importance(
|
||||
self,
|
||||
features: pd.DataFrame,
|
||||
scores: np.ndarray,
|
||||
labels: np.ndarray | None = None,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Calculate feature importance scores using the configured strategy.
|
||||
|
||||
Different strategies provide different interpretations:
|
||||
- SHAP: Shapley values indicating contribution to individual predictions
|
||||
- PERMUTATION: Decrease in model performance when feature is shuffled
|
||||
- IMPURITY: Gini impurity decrease from splits on each feature
|
||||
|
||||
Args:
|
||||
features: Feature matrix used for predictions.
|
||||
scores: Model prediction scores (unused for some strategies).
|
||||
labels: Ground truth labels (required for permutation importance).
|
||||
|
||||
Returns:
|
||||
np.ndarray: Feature importance scores, one per feature.
|
||||
"""
|
||||
match self.importance_strategy:
|
||||
case ImportanceStrategy.SHAP:
|
||||
# Use SHAP TreeExplainer for tree-based models
|
||||
explainer = shap.TreeExplainer(self.classifier)
|
||||
shap_values = explainer.shap_values(features)
|
||||
# Return mean SHAP values for the positive class (solvable)
|
||||
return shap_values.mean(axis=0)[:, 1] # type: ignore[no-any-return]
|
||||
|
||||
case ImportanceStrategy.PERMUTATION:
|
||||
# Permutation importance requires ground truth labels
|
||||
if labels is None:
|
||||
raise ValueError('Labels are required for permutation importance')
|
||||
result = permutation_importance(
|
||||
self.classifier,
|
||||
features,
|
||||
labels,
|
||||
n_repeats=10, # Number of permutation rounds for stability
|
||||
random_state=self.random_state,
|
||||
)
|
||||
return result.importances_mean # type: ignore[no-any-return]
|
||||
|
||||
case ImportanceStrategy.IMPURITY:
|
||||
# Use built-in feature importances from RandomForest
|
||||
return self.classifier.feature_importances_ # type: ignore[no-any-return]
|
||||
|
||||
case _:
|
||||
raise ValueError(
|
||||
f'Unknown importance strategy: {self.importance_strategy}'
|
||||
)
|
||||
|
||||
def add_features(self, features: list[Feature]) -> SolvabilityClassifier:
|
||||
"""
|
||||
Add new features to the classifier's featurizer.
|
||||
|
||||
Note: Adding features after training requires retraining the classifier
|
||||
since the feature space will have changed.
|
||||
|
||||
Args:
|
||||
features: List of Feature objects to add.
|
||||
|
||||
Returns:
|
||||
SolvabilityClassifier: Self for method chaining.
|
||||
"""
|
||||
for feature in features:
|
||||
if feature not in self.featurizer.features:
|
||||
self.featurizer.features.append(feature)
|
||||
return self
|
||||
|
||||
def forget_features(self, features: list[Feature]) -> SolvabilityClassifier:
|
||||
"""
|
||||
Remove features from the classifier's featurizer.
|
||||
|
||||
Note: Removing features after training requires retraining the classifier
|
||||
since the feature space will have changed.
|
||||
|
||||
Args:
|
||||
features: List of Feature objects to remove.
|
||||
|
||||
Returns:
|
||||
SolvabilityClassifier: Self for method chaining.
|
||||
"""
|
||||
for feature in features:
|
||||
try:
|
||||
self.featurizer.features.remove(feature)
|
||||
except ValueError:
|
||||
# Feature not in list, continue with others
|
||||
continue
|
||||
return self
|
||||
|
||||
@field_serializer('classifier')
|
||||
@staticmethod
|
||||
def _rfc_to_json(rfc: RandomForestClassifier) -> str:
|
||||
"""
|
||||
Convert a RandomForestClassifier to a JSON-compatible value (a string).
|
||||
"""
|
||||
return base64.b64encode(pickle.dumps(rfc)).decode('utf-8')
|
||||
|
||||
@field_validator('classifier', mode='before')
|
||||
@staticmethod
|
||||
def _json_to_rfc(value: str | RandomForestClassifier) -> RandomForestClassifier:
|
||||
"""
|
||||
Convert a JSON-compatible value (a string) back to a RandomForestClassifier.
|
||||
"""
|
||||
if isinstance(value, RandomForestClassifier):
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
model = pickle.loads(base64.b64decode(value))
|
||||
if isinstance(model, RandomForestClassifier):
|
||||
return model
|
||||
except Exception as e:
|
||||
raise ValueError(f'Failed to decode the classifier: {e}')
|
||||
|
||||
raise ValueError(
|
||||
'The classifier must be a RandomForestClassifier or a JSON-compatible dictionary.'
|
||||
)
|
||||
|
||||
def solvability_report(
|
||||
self, issue: str, llm_config: LLMConfig, **kwargs: Any
|
||||
) -> SolvabilityReport:
|
||||
"""
|
||||
Generate a solvability report for the given issue.
|
||||
|
||||
Args:
|
||||
issue: The issue description for which to generate the report.
|
||||
llm_config: Optional LLM configuration to use for feature extraction.
|
||||
kwargs: Additional metadata to include in the report.
|
||||
|
||||
Returns:
|
||||
SolvabilityReport: The generated solvability report.
|
||||
"""
|
||||
if not self.is_fitted:
|
||||
raise ValueError(
|
||||
'The classifier must be fitted before generating a report.'
|
||||
)
|
||||
|
||||
scores = self.predict_proba(pd.Series([issue]), llm_config=llm_config)
|
||||
|
||||
return SolvabilityReport(
|
||||
identifier=self.identifier,
|
||||
issue=issue,
|
||||
score=scores[0, 1],
|
||||
features=self.features_.iloc[0].to_dict(),
|
||||
samples=self.samples,
|
||||
importance_strategy=self.importance_strategy,
|
||||
# Unlike the features, the importances are just a series with no link
|
||||
# to the actual feature names. For that we have to recombine with the
|
||||
# feature identifiers.
|
||||
feature_importances=dict(
|
||||
zip(
|
||||
self.featurizer.feature_identifiers(),
|
||||
self.feature_importances_.tolist(),
|
||||
)
|
||||
),
|
||||
random_state=self.random_state,
|
||||
metadata=dict(kwargs) if kwargs else None,
|
||||
# Both cost and response_latency are columns in the cost_ DataFrame,
|
||||
# so we can get both by just unpacking the first row.
|
||||
**self.cost_.iloc[0].to_dict(),
|
||||
)
|
||||
|
||||
def __call__(
|
||||
self, issue: str, llm_config: LLMConfig, **kwargs: Any
|
||||
) -> SolvabilityReport:
|
||||
"""
|
||||
Generate a solvability report for the given issue.
|
||||
"""
|
||||
return self.solvability_report(issue, llm_config=llm_config, **kwargs)
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DifficultyLevel(Enum):
|
||||
"""Enum representing the difficulty level based on solvability score."""
|
||||
|
||||
EASY = ('EASY', 0.7, '🟢')
|
||||
MEDIUM = ('MEDIUM', 0.4, '🟡')
|
||||
HARD = ('HARD', 0.0, '🔴')
|
||||
|
||||
def __init__(self, label: str, threshold: float, emoji: str):
|
||||
self.label = label
|
||||
self.threshold = threshold
|
||||
self.emoji = emoji
|
||||
|
||||
@classmethod
|
||||
def from_score(cls, score: float) -> DifficultyLevel:
|
||||
"""Get difficulty level from a solvability score.
|
||||
|
||||
Returns the difficulty level with the highest threshold that is less than or equal to the given score.
|
||||
"""
|
||||
# Sort enum values by threshold in descending order
|
||||
sorted_levels = sorted(cls, key=lambda x: x.threshold, reverse=True)
|
||||
|
||||
# Find the first level where score meets the threshold
|
||||
for level in sorted_levels:
|
||||
if score >= level.threshold:
|
||||
return level
|
||||
|
||||
# This should never happen if thresholds are set correctly,
|
||||
# but return the lowest threshold level as fallback
|
||||
return sorted_levels[-1]
|
||||
|
||||
def format_display(self) -> str:
|
||||
"""Format the difficulty level for display."""
|
||||
return f'{self.emoji} **Solvability: {self.label}**'
|
||||
368
enterprise/integrations/solvability/models/featurizer.py
Normal file
368
enterprise/integrations/solvability/models/featurizer.py
Normal file
@@ -0,0 +1,368 @@
|
||||
import json
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
|
||||
class Feature(BaseModel):
|
||||
"""
|
||||
Represents a single boolean feature that can be extracted from issue descriptions.
|
||||
|
||||
Features are semantic properties of issues (e.g., "has_code_example", "requires_debugging")
|
||||
that are evaluated by LLMs and used as input to the solvability classifier.
|
||||
"""
|
||||
|
||||
identifier: str
|
||||
"""Unique identifier for the feature, used as column name in feature matrices."""
|
||||
|
||||
description: str
|
||||
"""Human-readable description of what the feature represents, used in LLM prompts."""
|
||||
|
||||
@property
|
||||
def to_tool_description_field(self) -> dict[str, Any]:
|
||||
"""
|
||||
Convert this feature to a JSON schema field for LLM tool calling.
|
||||
|
||||
Returns:
|
||||
dict: JSON schema field definition for this feature.
|
||||
"""
|
||||
return {
|
||||
'type': 'boolean',
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class EmbeddingDimension(BaseModel):
|
||||
"""
|
||||
Represents a single dimension (feature evaluation) within a feature embedding sample.
|
||||
|
||||
Each dimension corresponds to one feature being evaluated as true/false for a given issue.
|
||||
"""
|
||||
|
||||
feature_id: str
|
||||
"""Identifier of the feature being evaluated."""
|
||||
|
||||
result: bool
|
||||
"""Boolean result of the feature evaluation for this sample."""
|
||||
|
||||
|
||||
# Type alias for a single embedding sample - maps feature identifiers to boolean values
|
||||
EmbeddingSample = dict[str, bool]
|
||||
"""
|
||||
A single sample from the LLM evaluation of features for an issue.
|
||||
Maps feature identifiers to their boolean evaluations.
|
||||
"""
|
||||
|
||||
|
||||
class FeatureEmbedding(BaseModel):
|
||||
"""
|
||||
Represents the complete feature embedding for a single issue, including multiple samples
|
||||
and associated metadata about the LLM calls used to generate it.
|
||||
|
||||
Multiple samples are collected to account for LLM variability and provide more robust
|
||||
feature estimates through averaging.
|
||||
"""
|
||||
|
||||
samples: list[EmbeddingSample]
|
||||
"""List of individual feature evaluation samples from the LLM."""
|
||||
|
||||
prompt_tokens: int | None = None
|
||||
"""Total prompt tokens consumed across all LLM calls for this embedding."""
|
||||
|
||||
completion_tokens: int | None = None
|
||||
"""Total completion tokens generated across all LLM calls for this embedding."""
|
||||
|
||||
response_latency: float | None = None
|
||||
"""Total response latency (seconds) across all LLM calls for this embedding."""
|
||||
|
||||
@property
|
||||
def dimensions(self) -> list[str]:
|
||||
"""
|
||||
Get all unique feature identifiers present across all samples.
|
||||
|
||||
Returns:
|
||||
list[str]: List of feature identifiers that appear in at least one sample.
|
||||
"""
|
||||
dims: set[str] = set()
|
||||
for sample in self.samples:
|
||||
dims.update(sample.keys())
|
||||
return list(dims)
|
||||
|
||||
def coefficient(self, dimension: str) -> float | None:
|
||||
"""
|
||||
Calculate the average coefficient (0-1) for a specific feature dimension.
|
||||
|
||||
This computes the proportion of samples where the feature was evaluated as True,
|
||||
providing a continuous feature value for the classifier.
|
||||
|
||||
Args:
|
||||
dimension: Feature identifier to calculate coefficient for.
|
||||
|
||||
Returns:
|
||||
float | None: Average coefficient (0.0-1.0), or None if dimension not found.
|
||||
"""
|
||||
# Extract boolean values for this dimension, converting to 0/1
|
||||
values = [
|
||||
1 if v else 0
|
||||
for v in [sample.get(dimension) for sample in self.samples]
|
||||
if v is not None
|
||||
]
|
||||
if values:
|
||||
return sum(values) / len(values)
|
||||
return None
|
||||
|
||||
def to_row(self) -> dict[str, Any]:
|
||||
"""
|
||||
Convert the embedding to a flat dictionary suitable for DataFrame construction.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Dictionary with metadata fields and feature coefficients.
|
||||
"""
|
||||
return {
|
||||
'response_latency': self.response_latency,
|
||||
'prompt_tokens': self.prompt_tokens,
|
||||
'completion_tokens': self.completion_tokens,
|
||||
**{dimension: self.coefficient(dimension) for dimension in self.dimensions},
|
||||
}
|
||||
|
||||
def sample_entropy(self) -> dict[str, float]:
|
||||
"""
|
||||
Calculate the Shannon entropy of feature evaluations across samples.
|
||||
|
||||
Higher entropy indicates more variability in LLM responses for a feature,
|
||||
which may suggest ambiguity in the feature definition or issue description.
|
||||
|
||||
Returns:
|
||||
dict[str, float]: Mapping of feature identifiers to their entropy values (0-1).
|
||||
"""
|
||||
from collections import Counter
|
||||
from math import log2
|
||||
|
||||
entropy = {}
|
||||
for dimension in self.dimensions:
|
||||
# Count True/False occurrences for this feature across samples
|
||||
counts = Counter(sample.get(dimension, False) for sample in self.samples)
|
||||
total = sum(counts.values())
|
||||
if total == 0:
|
||||
entropy[dimension] = 0.0
|
||||
continue
|
||||
# Calculate Shannon entropy: -Σ(p * log2(p))
|
||||
entropy_value = -sum(
|
||||
(count / total) * log2(count / total)
|
||||
for count in counts.values()
|
||||
if count > 0
|
||||
)
|
||||
entropy[dimension] = entropy_value
|
||||
return entropy
|
||||
|
||||
|
||||
class Featurizer(BaseModel):
|
||||
"""
|
||||
Orchestrates LLM-based feature extraction from issue descriptions.
|
||||
|
||||
The Featurizer uses structured LLM tool calling to evaluate boolean features
|
||||
for issue descriptions. It handles prompt construction, tool schema generation,
|
||||
and batch processing with concurrency.
|
||||
"""
|
||||
|
||||
system_prompt: str
|
||||
"""System prompt that provides context and instructions to the LLM."""
|
||||
|
||||
message_prefix: str
|
||||
"""Prefix added to user messages before the issue description."""
|
||||
|
||||
features: list[Feature]
|
||||
"""List of features to extract from each issue description."""
|
||||
|
||||
def system_message(self) -> dict[str, Any]:
|
||||
"""
|
||||
Construct the system message for LLM conversations.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: System message dictionary for LLM API calls.
|
||||
"""
|
||||
return {
|
||||
'role': 'system',
|
||||
'content': self.system_prompt,
|
||||
}
|
||||
|
||||
def user_message(
|
||||
self, issue_description: str, set_cache: bool = True
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Construct the user message containing the issue description.
|
||||
|
||||
Args:
|
||||
issue_description: The description of the issue to analyze.
|
||||
set_cache: Whether to enable ephemeral caching for this message.
|
||||
Should be False for single samples to avoid cache overhead.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: User message dictionary for LLM API calls.
|
||||
"""
|
||||
message: dict[str, Any] = {
|
||||
'role': 'user',
|
||||
'content': f'{self.message_prefix}{issue_description}',
|
||||
}
|
||||
if set_cache:
|
||||
message['cache_control'] = {'type': 'ephemeral'}
|
||||
return message
|
||||
|
||||
@property
|
||||
def tool_choice(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get the tool choice configuration for forcing LLM to use the featurizer tool.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Tool choice configuration for LLM API calls.
|
||||
"""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {'name': 'call_featurizer'},
|
||||
}
|
||||
|
||||
@property
|
||||
def tool_description(self) -> dict[str, Any]:
|
||||
"""
|
||||
Generate the tool schema for the featurizer function.
|
||||
|
||||
Creates a JSON schema that describes the featurizer tool with all configured
|
||||
features as boolean parameters.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Complete tool description for LLM API calls.
|
||||
"""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'call_featurizer',
|
||||
'description': 'Record the features present in the issue.',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
feature.identifier: feature.to_tool_description_field
|
||||
for feature in self.features
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def embed(
|
||||
self,
|
||||
issue_description: str,
|
||||
llm_config: LLMConfig,
|
||||
temperature: float = 1.0,
|
||||
samples: int = 10,
|
||||
) -> FeatureEmbedding:
|
||||
"""
|
||||
Generate a feature embedding for a single issue description.
|
||||
|
||||
Makes multiple LLM calls to collect samples and reduce variance in feature evaluations.
|
||||
Each call uses tool calling to extract structured boolean feature values.
|
||||
|
||||
Args:
|
||||
issue_description: The description of the issue to analyze.
|
||||
llm_config: Configuration for the LLM to use.
|
||||
temperature: Sampling temperature for the model. Higher values increase randomness.
|
||||
samples: Number of samples to generate for averaging.
|
||||
|
||||
Returns:
|
||||
FeatureEmbedding: Complete embedding with samples and metadata.
|
||||
"""
|
||||
embedding_samples: list[dict[str, Any]] = []
|
||||
response_latency: float = 0.0
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
|
||||
# TODO: use llm registry
|
||||
llm = LLM(llm_config, service_id='solvability')
|
||||
|
||||
# Generate multiple samples to account for LLM variability
|
||||
for _ in range(samples):
|
||||
start_time = time.time()
|
||||
response = llm.completion(
|
||||
messages=[
|
||||
self.system_message(),
|
||||
self.user_message(issue_description, set_cache=(samples > 1)),
|
||||
],
|
||||
tools=[self.tool_description],
|
||||
tool_choice=self.tool_choice,
|
||||
temperature=temperature,
|
||||
)
|
||||
stop_time = time.time()
|
||||
|
||||
# Extract timing and token usage metrics
|
||||
latency = stop_time - start_time
|
||||
# Parse the structured tool call response containing feature evaluations
|
||||
features = response.choices[0].message.tool_calls[0].function.arguments # type: ignore[index, union-attr]
|
||||
embedding = json.loads(features)
|
||||
|
||||
# Accumulate results and metrics
|
||||
embedding_samples.append(embedding)
|
||||
prompt_tokens += response.usage.prompt_tokens # type: ignore[union-attr, attr-defined]
|
||||
completion_tokens += response.usage.completion_tokens # type: ignore[union-attr, attr-defined]
|
||||
response_latency += latency
|
||||
|
||||
return FeatureEmbedding(
|
||||
samples=embedding_samples,
|
||||
response_latency=response_latency,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
)
|
||||
|
||||
def embed_batch(
|
||||
self,
|
||||
issue_descriptions: list[str],
|
||||
llm_config: LLMConfig,
|
||||
temperature: float = 1.0,
|
||||
samples: int = 10,
|
||||
) -> list[FeatureEmbedding]:
|
||||
"""
|
||||
Generate embeddings for a batch of issue descriptions using concurrent processing.
|
||||
|
||||
Processes multiple issues in parallel to improve throughput while maintaining
|
||||
result ordering.
|
||||
|
||||
Args:
|
||||
issue_descriptions: List of issue descriptions to analyze.
|
||||
llm_config: Configuration for the LLM to use.
|
||||
temperature: Sampling temperature for the model.
|
||||
samples: Number of samples to generate per issue.
|
||||
|
||||
Returns:
|
||||
list[FeatureEmbedding]: List of embeddings in the same order as input.
|
||||
"""
|
||||
with ThreadPoolExecutor() as executor:
|
||||
# Submit all embedding tasks concurrently
|
||||
future_to_desc = {
|
||||
executor.submit(
|
||||
self.embed,
|
||||
desc,
|
||||
llm_config,
|
||||
temperature=temperature,
|
||||
samples=samples,
|
||||
): i
|
||||
for i, desc in enumerate(issue_descriptions)
|
||||
}
|
||||
|
||||
# Collect results in original order to maintain consistency
|
||||
results: list[FeatureEmbedding] = [None] * len(issue_descriptions) # type: ignore[list-item]
|
||||
for future in as_completed(future_to_desc):
|
||||
index = future_to_desc[future]
|
||||
results[index] = future.result()
|
||||
|
||||
return results
|
||||
|
||||
def feature_identifiers(self) -> list[str]:
|
||||
"""
|
||||
Get the identifiers of all configured features.
|
||||
|
||||
Returns:
|
||||
list[str]: List of feature identifiers in the order they were defined.
|
||||
"""
|
||||
return [feature.identifier for feature in self.features]
|
||||
@@ -0,0 +1,23 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ImportanceStrategy(str, Enum):
|
||||
"""
|
||||
Strategy to use for calculating feature importances, which are used to estimate the predictive power of each feature
|
||||
in training loops and explanations.
|
||||
"""
|
||||
|
||||
SHAP = 'shap'
|
||||
"""
|
||||
Use SHAP (SHapley Additive exPlanations) to calculate feature importances.
|
||||
"""
|
||||
|
||||
PERMUTATION = 'permutation'
|
||||
"""
|
||||
Use the permutation-based feature importances.
|
||||
"""
|
||||
|
||||
IMPURITY = 'impurity'
|
||||
"""
|
||||
Use the impurity-based feature importances from the RandomForestClassifier.
|
||||
"""
|
||||
87
enterprise/integrations/solvability/models/report.py
Normal file
87
enterprise/integrations/solvability/models/report.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from integrations.solvability.models.importance_strategy import ImportanceStrategy
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SolvabilityReport(BaseModel):
|
||||
"""
|
||||
Comprehensive report containing solvability predictions and analysis for a single issue.
|
||||
|
||||
This report includes the solvability score, extracted feature values, feature importance analysis,
|
||||
cost metrics (tokens and latency), and metadata about the prediction process. It serves as the
|
||||
primary output format for solvability analysis and can be used for logging, debugging, and
|
||||
generating human-readable summaries.
|
||||
"""
|
||||
|
||||
identifier: str
|
||||
"""
|
||||
The identifier of the solvability model used to generate the report.
|
||||
"""
|
||||
|
||||
issue: str
|
||||
"""
|
||||
The issue description for which the solvability is predicted.
|
||||
|
||||
This field is exactly the input to the solvability model.
|
||||
"""
|
||||
|
||||
score: float
|
||||
"""
|
||||
[0, 1]-valued score indicating the likelihood of the issue being solvable.
|
||||
"""
|
||||
|
||||
prompt_tokens: int
|
||||
"""
|
||||
Total number of prompt tokens used in API calls made to generate the features.
|
||||
"""
|
||||
|
||||
completion_tokens: int
|
||||
"""
|
||||
Total number of completion tokens used in API calls made to generate the features.
|
||||
"""
|
||||
|
||||
response_latency: float
|
||||
"""
|
||||
Total response latency of API calls made to generate the features.
|
||||
"""
|
||||
|
||||
features: dict[str, float]
|
||||
"""
|
||||
[0, 1]-valued scores for each feature in the model.
|
||||
|
||||
These are the values fed to the random forest classifier to generate the solvability score.
|
||||
"""
|
||||
|
||||
samples: int
|
||||
"""
|
||||
Number of samples used to compute the feature embedding coefficients.
|
||||
"""
|
||||
|
||||
importance_strategy: ImportanceStrategy
|
||||
"""
|
||||
Strategy used to calculate feature importances.
|
||||
"""
|
||||
|
||||
feature_importances: dict[str, float]
|
||||
"""
|
||||
Importance scores for each feature in the model.
|
||||
|
||||
Interpretation of these scores depends on the importance strategy used.
|
||||
"""
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
"""
|
||||
Datetime when the report was created.
|
||||
"""
|
||||
|
||||
random_state: int | None = None
|
||||
"""
|
||||
Classifier random state used when generating this report.
|
||||
"""
|
||||
|
||||
metadata: dict[str, Any] | None = None
|
||||
"""
|
||||
Metadata for logging and debugging purposes.
|
||||
"""
|
||||
172
enterprise/integrations/solvability/models/summary.py
Normal file
172
enterprise/integrations/solvability/models/summary.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from integrations.solvability.models.difficulty_level import DifficultyLevel
|
||||
from integrations.solvability.models.report import SolvabilityReport
|
||||
from integrations.solvability.prompts import load_prompt
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.llm import LLM
|
||||
|
||||
|
||||
class SolvabilitySummary(BaseModel):
|
||||
"""Summary of the solvability analysis in human-readable format."""
|
||||
|
||||
score: float
|
||||
"""
|
||||
Solvability score indicating the likelihood of the issue being solvable.
|
||||
"""
|
||||
|
||||
summary: str
|
||||
"""
|
||||
The executive summary content generated by the LLM.
|
||||
"""
|
||||
|
||||
actionable_feedback: str
|
||||
"""
|
||||
Actionable feedback content generated by the LLM.
|
||||
"""
|
||||
|
||||
positive_feedback: str
|
||||
"""
|
||||
Positive feedback content generated by the LLM, highlighting what is good about the issue.
|
||||
"""
|
||||
|
||||
prompt_tokens: int
|
||||
"""
|
||||
Number of prompt tokens used in the API call to generate the summary.
|
||||
"""
|
||||
|
||||
completion_tokens: int
|
||||
"""
|
||||
Number of completion tokens used in the API call to generate the summary.
|
||||
"""
|
||||
|
||||
response_latency: float
|
||||
"""
|
||||
Response latency of the API call to generate the summary.
|
||||
"""
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
"""
|
||||
Datetime when the summary was created.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def tool_description() -> dict[str, Any]:
|
||||
"""Get the tool description for the LLM."""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'solvability_summary',
|
||||
'description': 'Generate a human-readable summary of the solvability analysis.',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'summary': {
|
||||
'type': 'string',
|
||||
'description': 'A high-level (at most two sentences) summary of the solvability report.',
|
||||
},
|
||||
'actionable_feedback': {
|
||||
'type': 'string',
|
||||
'description': (
|
||||
'Bullet list of 1-3 pieces of actionable feedback on how the user can address the lowest scoring relevant features.'
|
||||
),
|
||||
},
|
||||
'positive_feedback': {
|
||||
'type': 'string',
|
||||
'description': (
|
||||
'Bullet list of 1-3 pieces of positive feedback on the issue, highlighting what is good about it.'
|
||||
),
|
||||
},
|
||||
},
|
||||
'required': ['summary', 'actionable_feedback'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def tool_choice() -> dict[str, Any]:
|
||||
"""Get the tool choice for the LLM."""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'solvability_summary',
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def system_message() -> dict[str, Any]:
|
||||
"""Get the system message for the LLM."""
|
||||
return {
|
||||
'role': 'system',
|
||||
'content': load_prompt('summary_system_message'),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def user_message(report: SolvabilityReport) -> dict[str, Any]:
|
||||
"""Get the user message for the LLM."""
|
||||
return {
|
||||
'role': 'user',
|
||||
'content': load_prompt(
|
||||
'summary_user_message',
|
||||
report=report.model_dump(),
|
||||
difficulty_level=DifficultyLevel.from_score(report.score).value[0],
|
||||
),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_report(report: SolvabilityReport, llm: LLM) -> SolvabilitySummary:
|
||||
"""Create a SolvabilitySummary from a SolvabilityReport."""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
response = llm.completion(
|
||||
messages=[
|
||||
SolvabilitySummary.system_message(),
|
||||
SolvabilitySummary.user_message(report),
|
||||
],
|
||||
tools=[SolvabilitySummary.tool_description()],
|
||||
tool_choice=SolvabilitySummary.tool_choice(),
|
||||
)
|
||||
response_latency = time.time() - start_time
|
||||
|
||||
# Grab the arguments from the forced function call
|
||||
arguments = json.loads(
|
||||
response.choices[0].message.tool_calls[0].function.arguments
|
||||
)
|
||||
|
||||
return SolvabilitySummary(
|
||||
# The score is copied directly from the report
|
||||
score=report.score,
|
||||
# Performance and usage metrics are pulled from the response
|
||||
prompt_tokens=response.usage.prompt_tokens,
|
||||
completion_tokens=response.usage.completion_tokens,
|
||||
response_latency=response_latency,
|
||||
# Every other field should be taken from the forced function call
|
||||
**arguments,
|
||||
)
|
||||
|
||||
def format_as_markdown(self) -> str:
|
||||
"""Format the summary content as Markdown."""
|
||||
# Convert score to difficulty level enum
|
||||
difficulty_level = DifficultyLevel.from_score(self.score)
|
||||
|
||||
# Create the main difficulty display
|
||||
result = f'{difficulty_level.format_display()}\n\n{self.summary}'
|
||||
|
||||
# If not easy, show the three features with lowest importance scores
|
||||
if difficulty_level != DifficultyLevel.EASY:
|
||||
# Add dropdown with lowest importance features
|
||||
result += '\n\nYou can make the issue easier to resolve by addressing these concerns in the conversation:\n\n'
|
||||
result += self.actionable_feedback
|
||||
|
||||
# If the difficulty isn't hard, add some positive feedback
|
||||
if difficulty_level != DifficultyLevel.HARD:
|
||||
result += '\n\nPositive feedback:\n\n'
|
||||
result += self.positive_feedback
|
||||
|
||||
return result
|
||||
13
enterprise/integrations/solvability/prompts/__init__.py
Normal file
13
enterprise/integrations/solvability/prompts/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
|
||||
|
||||
def load_prompt(prompt: str, **kwargs) -> str:
|
||||
"""Load a prompt by name. Passes all the keyword arguments to the prompt template."""
|
||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(Path(__file__).parent))
|
||||
template = env.get_template(f'{prompt}.j2')
|
||||
return template.render(**kwargs)
|
||||
|
||||
|
||||
__all__ = ['load_prompt']
|
||||
@@ -0,0 +1,10 @@
|
||||
You are a helpful assistant that generates human-readable summaries of solvability reports.
|
||||
The report predicts how likely it is that the issue can be resolved, and is produced purely based on the information provided in the issue description and comments.
|
||||
The report explains which features are present in the issue and how impactful they are to the solvability score (using SHAP values).
|
||||
Your task is to create a concise, high-level summary of the solvability analysis,
|
||||
with an emphasis on the key factors that make the issue easy or hard to resolve.
|
||||
Focus on the features with extreme scores, BUT ONLY if they are related to the issue at hand after careful consideration.
|
||||
You should NEVER mention: SHAP, scores, feature names, or technical metrics.
|
||||
You will also be given the expected difficulty of the issue, as EASY/MEDIUM/HARD.
|
||||
Be sure to frame your responses with that difficulty in mind.
|
||||
For example, if the issue is HARD you should not describe it as "straightforward".
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user