Compare commits

..

14 Commits

Author SHA1 Message Date
mamoodi 5ea17a302f Release 0.34.0 2025-04-22 15:29:58 -04:00
Robert Brennan b0a9938e6c Always run git init in SaaS mode regardless of workspace_base setting (#8014)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-22 17:43:20 +00:00
Rohit Malhotra 039fe295a4 Add RateLimitError and handle rate limiting in GitLab and GitHub services (#8003)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-04-22 16:30:41 +00:00
dependabot[bot] 8f3ff1210e chore(deps): bump the version-all group with 6 updates (#8009)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 15:16:41 +00:00
Peter Hamilton 5e1e685493 fix: Use SANDBOX_BASE_CONTAINER_IMAGE in resolver workflow (#7956)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-22 16:15:49 +02:00
sp.wack e9f2b72ea5 chore: Better home screen (#7784)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-22 13:15:41 +00:00
Rohit Malhotra 986b90be0a [Fix]: fetch latest token when existing token doesn't exist (#8000) 2025-04-22 03:22:48 +00:00
Robert Brennan bf9f2aa7a5 Initialize git repo in workspace when no GitHub repo is selected (#7904)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-21 18:34:42 -04:00
Engel Nyst b3bd3924a0 Fix and simplify local runtime init (#7997) 2025-04-22 00:24:22 +02:00
Rohit Malhotra 0de50153a0 Add HTTP method option to Git service fetch_data functions (#7996)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-21 18:15:05 -04:00
Xingyao Wang a04024a239 refactor: file viewer server so it is accessible via localhost without authentication (#7987)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-21 22:12:06 +00:00
Rohit Malhotra 1e509a70d4 [Fix]: Dedup token verification logic in resolver (#7967)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-21 20:34:29 +00:00
Engel Nyst 300a59853b Quick fix local runtime (#7991) 2025-04-21 20:28:30 +00:00
Rohit Malhotra 2514b200c5 Fix dictionary changed size during iteration error in EventStream (#7984)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-21 16:21:30 -04:00
88 changed files with 2481 additions and 2667 deletions
+1 -1
View File
@@ -179,7 +179,7 @@ jobs:
echo "MAX_ITERATIONS=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV
echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.PAT_TOKEN || github.token }}" >> $GITHUB_ENV
echo "SANDBOX_ENV_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $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
+1 -1
View File
@@ -118,7 +118,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.33-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.34-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -52,17 +52,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
+1 -1
View File
@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.33-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.34-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik}
#- 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-state for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -61,7 +61,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```
@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -56,6 +56,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```
@@ -13,16 +13,16 @@
La façon la plus simple d'exécuter OpenHands est avec Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).
@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
@@ -34,7 +34,7 @@ Docker で OpenHands を CLI モードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -44,7 +44,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```
@@ -31,7 +31,7 @@ DockerでOpenHandsをヘッドレスモードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -42,7 +42,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
@@ -25,7 +25,7 @@ nikolaik の `SANDBOX_RUNTIME_CONTAINER_IMAGE` は、ランタイムサーバー
```bash
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-v $WORKSPACE_BASE:/opt/workspace_base \
@@ -82,5 +82,5 @@ docker network create openhands-network
# 分離されたネットワークで OpenHands を実行
docker run # ... \
--network openhands-network \
docker.all-hands.dev/all-hands-ai/openhands:0.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
@@ -35,7 +35,7 @@ Para executar o OpenHands no modo CLI com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```
@@ -32,7 +32,7 @@ Para executar o OpenHands no modo Headless com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "escreva um script bash que imprima oi"
```
@@ -58,17 +58,17 @@
A maneira mais fácil de executar o OpenHands é no Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
Você encontrará o OpenHands em execução em http://localhost:3000!
@@ -13,7 +13,7 @@ Este é o Runtime padrão que é usado quando você inicia o OpenHands. Você po
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```
@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -57,6 +57,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```
@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。
@@ -11,7 +11,7 @@
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
+2 -2
View File
@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```
+2 -2
View File
@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-v ~/.openhands-state:/.openhands-state \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
+3 -3
View File
@@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
You'll find OpenHands running at http://localhost:3000!
-2098
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { AuthProvider } from "#/context/auth-context";
import { HomeHeader } from "#/components/features/home/home-header";
import OpenHands from "#/api/open-hands";
const renderHomeHeader = () => {
const RouterStub = createRoutesStub([
{
Component: HomeHeader,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthProvider>
</Provider>
),
});
};
describe("HomeHeader", () => {
it("should create an empty conversation and redirect when pressing the launch from scratch button", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderHomeHeader();
const launchButton = screen.getByRole("button", {
name: /launch from scratch/i,
});
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
undefined,
undefined,
[],
undefined,
);
// expect to be redirected to /conversations/:conversationId
await screen.findByTestId("conversation-screen");
});
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
renderHomeHeader();
const launchButton = screen.getByRole("button", {
name: /launch from scratch/i,
});
await userEvent.click(launchButton);
expect(launchButton).toHaveTextContent(/Loading/i);
expect(launchButton).toBeDisabled();
});
});
@@ -0,0 +1,216 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { setupStore } from "test-utils";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { GitRepository } from "#/types/git";
import * as GitService from "#/api/git";
import { RepoConnector } from "#/components/features/home/repo-connector";
const renderRepoConnector = (initialProvidersAreSet = true) => {
const mockRepoSelection = vi.fn();
const RouterStub = createRoutesStub([
{
Component: () => <RepoConnector onRepoSelection={mockRepoSelection} />,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
{
Component: () => <div data-testid="settings-screen" />,
path: "/settings",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet={initialProvidersAreSet}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthProvider>
</Provider>
),
});
};
const MOCK_RESPOSITORIES: GitRepository[] = [
{
id: 1,
full_name: "rbren/polaris",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "All-Hands-AI/OpenHands",
git_provider: "github",
is_public: true,
},
];
describe("RepoConnector", () => {
it("should render the repository connector section", () => {
renderRepoConnector();
screen.getByTestId("repo-connector");
});
it("should render the available repositories in the dropdown", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
const dropdown = screen.getByTestId("repo-dropdown");
await userEvent.click(dropdown);
await waitFor(() => {
screen.getByText("rbren/polaris");
screen.getByText("All-Hands-AI/OpenHands");
});
});
it("should only enable the launch button if a repo is selected", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
const launchButton = screen.getByTestId("repo-launch-button");
expect(launchButton).toBeDisabled();
const dropdown = screen.getByTestId("repo-dropdown");
await userEvent.click(dropdown);
await userEvent.click(screen.getByText("rbren/polaris"));
expect(launchButton).toBeEnabled();
});
it("should render the 'add git(hub|lab) repos' links if saas mode", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderRepoConnector();
await screen.findByText("Add GitHub repos");
});
it("should not render the 'add git(hub|lab) repos' links if oss mode", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "oss",
});
renderRepoConnector();
expect(screen.queryByText("Add GitHub repos")).not.toBeInTheDocument();
expect(screen.queryByText("Add GitLab repos")).not.toBeInTheDocument();
});
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderRepoConnector();
const repoConnector = screen.getByTestId("repo-connector");
const launchButton =
within(repoConnector).getByTestId("repo-launch-button");
await userEvent.click(launchButton);
// repo not selected yet
expect(createConversationSpy).not.toHaveBeenCalled();
// select a repository from the dropdown
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
await userEvent.click(dropdown);
const repoOption = screen.getByText("rbren/polaris");
await userEvent.click(repoOption);
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
{
full_name: "rbren/polaris",
git_provider: "github",
id: 1,
is_public: true,
},
undefined,
[],
undefined,
);
});
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
const launchButton = screen.getByTestId("repo-launch-button");
const dropdown = screen.getByTestId("repo-dropdown");
await userEvent.click(dropdown);
await userEvent.click(screen.getByText("rbren/polaris"));
await userEvent.click(launchButton);
expect(launchButton).toBeDisabled();
expect(launchButton).toHaveTextContent(/Loading/i);
});
it("should not display a button to settings if the user is signed in with their git provider", async () => {
renderRepoConnector(true);
expect(
screen.queryByTestId("navigate-to-settings-button"),
).not.toBeInTheDocument();
});
it("should display a button to settings if the user needs to sign in with their git provider", async () => {
renderRepoConnector(false);
const goToSettingsButton = await screen.findByTestId(
"navigate-to-settings-button",
);
const dropdown = screen.queryByTestId("repo-dropdown");
const launchButton = screen.queryByTestId("repo-launch-button");
const providerLinks = screen.queryAllByText(/add git(hub|lab) repos/i);
expect(dropdown).not.toBeInTheDocument();
expect(launchButton).not.toBeInTheDocument();
expect(providerLinks.length).toBe(0);
expect(goToSettingsButton).toBeInTheDocument();
await userEvent.click(goToSettingsButton);
await screen.findByTestId("settings-screen");
});
});
@@ -0,0 +1,186 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { TaskCard } from "#/components/features/home/tasks/task-card";
import * as GitService from "#/api/git";
import { GitRepository } from "#/types/git";
import {
getFailingChecksPrompt,
getMergeConflictPrompt,
getOpenIssuePrompt,
getUnresolvedCommentsPrompt,
} from "#/components/features/home/tasks/get-prompt-for-query";
const MOCK_TASK_1: SuggestedTask = {
issue_number: 123,
repo: "repo1",
title: "Task 1",
task_type: "MERGE_CONFLICTS",
};
const MOCK_TASK_2: SuggestedTask = {
issue_number: 456,
repo: "repo2",
title: "Task 2",
task_type: "FAILING_CHECKS",
};
const MOCK_TASK_3: SuggestedTask = {
issue_number: 789,
repo: "repo3",
title: "Task 3",
task_type: "UNRESOLVED_COMMENTS",
};
const MOCK_TASK_4: SuggestedTask = {
issue_number: 101112,
repo: "repo4",
title: "Task 4",
task_type: "OPEN_ISSUE",
};
const MOCK_RESPOSITORIES: GitRepository[] = [
{ id: 1, full_name: "repo1", git_provider: "github", is_public: true },
{ id: 2, full_name: "repo2", git_provider: "github", is_public: true },
{ id: 3, full_name: "repo3", git_provider: "gitlab", is_public: true },
{ id: 4, full_name: "repo4", git_provider: "gitlab", is_public: true },
];
const renderTaskCard = (task = MOCK_TASK_1) => {
const RouterStub = createRoutesStub([
{
Component: () => <TaskCard task={task} />,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthProvider>
</Provider>
),
});
};
describe("TaskCard", () => {
it("format the issue id", async () => {
renderTaskCard();
const taskId = screen.getByTestId("task-id");
expect(taskId).toHaveTextContent(/#123/i);
});
it("should call createConversation when clicking the launch button", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard();
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalled();
});
describe("creating conversation prompts", () => {
beforeEach(() => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
});
it("should call create conversation with the merge conflict prompt", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard(MOCK_TASK_1);
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[0],
getMergeConflictPrompt(MOCK_TASK_1.issue_number, MOCK_TASK_1.repo),
[],
undefined,
);
});
it("should call create conversation with the failing checks prompt", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard(MOCK_TASK_2);
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[1],
getFailingChecksPrompt(MOCK_TASK_2.issue_number, MOCK_TASK_2.repo),
[],
undefined,
);
});
it("should call create conversation with the unresolved comments prompt", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard(MOCK_TASK_3);
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[2],
getUnresolvedCommentsPrompt(MOCK_TASK_3.issue_number, MOCK_TASK_3.repo),
[],
undefined,
);
});
it("should call create conversation with the open issue prompt", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard(MOCK_TASK_4);
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[3],
getOpenIssuePrompt(MOCK_TASK_4.issue_number, MOCK_TASK_4.repo),
[],
undefined,
);
});
});
it("should disable the launch button and update text content when creating a conversation", async () => {
renderTaskCard();
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(launchButton).toHaveTextContent(/Loading/i);
expect(launchButton).toBeDisabled();
});
});
@@ -0,0 +1,113 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import userEvent from "@testing-library/user-event";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers";
import { AuthProvider } from "#/context/auth-context";
const renderTaskSuggestions = (initialProvidersAreSet = true) => {
const RouterStub = createRoutesStub([
{
Component: TaskSuggestions,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
{
Component: () => <div data-testid="settings-screen" />,
path: "/settings",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet={initialProvidersAreSet}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthProvider>
</Provider>
),
});
};
describe("TaskSuggestions", () => {
const getSuggestedTasksSpy = vi.spyOn(
SuggestionsService,
"getSuggestedTasks",
);
afterEach(() => {
vi.clearAllMocks();
});
it("should render the task suggestions section", () => {
renderTaskSuggestions();
screen.getByTestId("task-suggestions");
});
it("should render an empty message if there are no tasks", async () => {
getSuggestedTasksSpy.mockResolvedValue([]);
renderTaskSuggestions();
await screen.findByText(/No tasks available/i);
});
it("should render the task groups with the correct titles", async () => {
getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
renderTaskSuggestions();
await waitFor(() => {
MOCK_TASKS.forEach((taskGroup) => {
screen.getByText(taskGroup.title);
});
});
});
it("should render the task cards with the correct task details", async () => {
getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
renderTaskSuggestions();
await waitFor(() => {
MOCK_TASKS.forEach((task) => {
screen.getByText(task.title);
});
});
});
it("should render skeletons when loading", async () => {
getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
renderTaskSuggestions();
const skeletons = screen.getAllByTestId("task-group-skeleton");
expect(skeletons.length).toBeGreaterThan(0);
await waitFor(() => {
MOCK_TASKS.forEach((taskGroup) => {
screen.getByText(taskGroup.title);
});
});
expect(screen.queryByTestId("task-group-skeleton")).not.toBeInTheDocument();
});
it("should display a button to settings if the user needs to sign in with their git provider", async () => {
renderTaskSuggestions(false);
expect(getSuggestedTasksSpy).not.toHaveBeenCalled();
const goToSettingsButton = await screen.findByTestId(
"navigate-to-settings-button",
);
expect(goToSettingsButton).toBeInTheDocument();
await userEvent.click(goToSettingsButton);
await screen.findByTestId("settings-screen");
});
});
@@ -0,0 +1,370 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import { Provider } from "react-redux";
import { setupStore } from "test-utils";
import { AxiosError } from "axios";
import HomeScreen from "#/routes/home";
import { AuthProvider } from "#/context/auth-context";
import * as GitService from "#/api/git";
import { GitRepository } from "#/types/git";
import OpenHands from "#/api/open-hands";
import MainApp from "#/routes/root-layout";
const createAxiosNotFoundErrorObject = () =>
new AxiosError(
"Request failed with status code 404",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 404,
statusText: "Not Found",
data: { message: "Settings not found" },
headers: {},
// @ts-expect-error - we only need the response object for this test
config: {},
},
);
const RouterStub = createRoutesStub([
{
Component: MainApp,
path: "/",
children: [
{
Component: HomeScreen,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
{
Component: () => <div data-testid="settings-screen" />,
path: "/settings",
},
],
},
]);
const renderHomeScreen = (initialProvidersAreSet = true) =>
render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet={initialProvidersAreSet}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthProvider>
</Provider>
),
});
const MOCK_RESPOSITORIES: GitRepository[] = [
{
id: 1,
full_name: "octocat/hello-world",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "octocat/earth",
git_provider: "github",
is_public: true,
},
];
describe("HomeScreen", () => {
it("should render", () => {
renderHomeScreen();
screen.getByTestId("home-screen");
});
it("should render the repository connector and suggested tasks sections", async () => {
renderHomeScreen();
screen.getByTestId("repo-connector");
screen.getByTestId("task-suggestions");
});
it("should filter the suggested tasks based on the selected repository", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderHomeScreen();
const taskSuggestions = screen.getByTestId("task-suggestions");
// Initially, all tasks should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
// Select a repository from the dropdown
const repoConnector = screen.getByTestId("repo-connector");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
await userEvent.click(dropdown);
const repoOption = screen.getAllByText("octocat/hello-world")[1];
await userEvent.click(repoOption);
// After selecting a repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
expect(
within(taskSuggestions).queryByText("octocat/earth"),
).not.toBeInTheDocument();
});
});
it("should reset the filtered tasks when the selected repository is cleared", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderHomeScreen();
const taskSuggestions = screen.getByTestId("task-suggestions");
// Initially, all tasks should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
// Select a repository from the dropdown
const repoConnector = screen.getByTestId("repo-connector");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
await userEvent.click(dropdown);
const repoOption = screen.getAllByText("octocat/hello-world")[1];
await userEvent.click(repoOption);
// After selecting a repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
expect(
within(taskSuggestions).queryByText("octocat/earth"),
).not.toBeInTheDocument();
});
// Clear the selected repository
await userEvent.clear(dropdown);
// All tasks should be visible again
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
});
describe("launch buttons", () => {
const setupLaunchButtons = async () => {
let headerLaunchButton = screen.getByTestId("header-launch-button");
let repoLaunchButton = screen.getByTestId("repo-launch-button");
let tasksLaunchButtons =
await screen.findAllByTestId("task-launch-button");
// Select a repository from the dropdown to enable the repo launch button
const repoConnector = screen.getByTestId("repo-connector");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
await userEvent.click(dropdown);
const repoOption = screen.getAllByText("octocat/hello-world")[1];
await userEvent.click(repoOption);
expect(headerLaunchButton).not.toBeDisabled();
expect(repoLaunchButton).not.toBeDisabled();
tasksLaunchButtons.forEach((button) => {
expect(button).not.toBeDisabled();
});
headerLaunchButton = screen.getByTestId("header-launch-button");
repoLaunchButton = screen.getByTestId("repo-launch-button");
tasksLaunchButtons = await screen.findAllByTestId("task-launch-button");
return {
headerLaunchButton,
repoLaunchButton,
tasksLaunchButtons,
};
};
beforeEach(() => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
});
it("should disable the other launch buttons when the header launch button is clicked", async () => {
renderHomeScreen();
const { headerLaunchButton, repoLaunchButton } =
await setupLaunchButtons();
const tasksLaunchButtonsAfter =
await screen.findAllByTestId("task-launch-button");
// All other buttons should be disabled when the header button is clicked
await userEvent.click(headerLaunchButton);
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
it("should disable the other launch buttons when the repo launch button is clicked", async () => {
renderHomeScreen();
const { headerLaunchButton, repoLaunchButton } =
await setupLaunchButtons();
const tasksLaunchButtonsAfter =
await screen.findAllByTestId("task-launch-button");
// All other buttons should be disabled when the repo button is clicked
await userEvent.click(repoLaunchButton);
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
it("should disable the other launch buttons when any task launch button is clicked", async () => {
renderHomeScreen();
const { headerLaunchButton, repoLaunchButton, tasksLaunchButtons } =
await setupLaunchButtons();
const tasksLaunchButtonsAfter =
await screen.findAllByTestId("task-launch-button");
// All other buttons should be disabled when the task button is clicked
await userEvent.click(tasksLaunchButtons[0]);
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
});
it("should hide the suggested tasks section if not authed with git(hub|lab)", async () => {
renderHomeScreen(false);
const taskSuggestions = screen.queryByTestId("task-suggestions");
const repoConnector = screen.getByTestId("repo-connector");
expect(taskSuggestions).not.toBeInTheDocument();
expect(repoConnector).toBeInTheDocument();
});
});
describe("Settings 404", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
it("should open the settings modal if GET /settings fails with a 404", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderHomeScreen();
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
});
it("should navigate to the settings screen when clicking the advanced settings button", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
const user = userEvent.setup();
renderHomeScreen();
const settingsScreen = screen.queryByTestId("settings-screen");
expect(settingsScreen).not.toBeInTheDocument();
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
const advancedSettingsButton = await screen.findByTestId(
"advanced-settings-link",
);
await user.click(advancedSettingsButton);
const settingsScreenAfter = await screen.findByTestId("settings-screen");
expect(settingsScreenAfter).toBeInTheDocument();
const settingsModalAfter = screen.queryByTestId("ai-config-modal");
expect(settingsModalAfter).not.toBeInTheDocument();
});
it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => {
// @ts-expect-error - we only need APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderHomeScreen();
// small hack to wait for the modal to not appear
await expect(
screen.findByTestId("ai-config-modal", {}, { timeout: 1000 }),
).rejects.toThrow();
});
});
describe("Setup Payment modal", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
it("should only render if SaaS mode and is new user", async () => {
// @ts-expect-error - we only need the APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
},
});
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderHomeScreen();
const setupPaymentModal = await screen.findByTestId(
"proceed-to-stripe-button",
);
expect(setupPaymentModal).toBeInTheDocument();
});
});
-177
View File
@@ -1,177 +0,0 @@
import { createRoutesStub } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import userEvent from "@testing-library/user-event";
import { screen } from "@testing-library/react";
import { AxiosError } from "axios";
import MainApp from "#/routes/root-layout";
import SettingsScreen from "#/routes/settings";
import Home from "#/routes/home";
import OpenHands from "#/api/open-hands";
const createAxiosNotFoundErrorObject = () =>
new AxiosError(
"Request failed with status code 404",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 404,
statusText: "Not Found",
data: { message: "Settings not found" },
headers: {},
// @ts-expect-error - we only need the response object for this test
config: {},
},
);
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const RouterStub = createRoutesStub([
{
// layout route
Component: MainApp,
path: "/",
children: [
{
// home route
Component: Home,
path: "/",
},
{
Component: SettingsScreen,
path: "/settings",
},
],
},
]);
afterEach(() => {
vi.clearAllMocks();
});
describe("Home Screen", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
it("should render the home screen", () => {
renderWithProviders(<RouterStub initialEntries={["/"]} />);
});
it("should navigate to the settings screen when the settings button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const settingsButton = await screen.findByTestId("settings-button");
await user.click(settingsButton);
const settingsScreen = await screen.findByTestId("settings-screen");
expect(settingsScreen).toBeInTheDocument();
});
it("should navigate to the settings when pressing 'Connect to GitHub' if the user isn't authenticated", async () => {
// @ts-expect-error - we only need APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
const user = userEvent.setup();
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const connectToGitHubButton =
await screen.findByTestId("connect-to-github");
await user.click(connectToGitHubButton);
const settingsScreen = await screen.findByTestId("settings-screen");
expect(settingsScreen).toBeInTheDocument();
});
});
describe("Settings 404", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
it("should open the settings modal if GET /settings fails with a 404", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
});
it("should navigate to the settings screen when clicking the advanced settings button", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
const user = userEvent.setup();
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const settingsScreen = screen.queryByTestId("settings-screen");
expect(settingsScreen).not.toBeInTheDocument();
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
const advancedSettingsButton = await screen.findByTestId(
"advanced-settings-link",
);
await user.click(advancedSettingsButton);
const settingsScreenAfter = await screen.findByTestId("settings-screen");
expect(settingsScreenAfter).toBeInTheDocument();
const settingsModalAfter = screen.queryByTestId("ai-config-modal");
expect(settingsModalAfter).not.toBeInTheDocument();
});
it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => {
// @ts-expect-error - we only need APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderWithProviders(<RouterStub initialEntries={["/"]} />);
// small hack to wait for the modal to not appear
await expect(
screen.findByTestId("ai-config-modal", {}, { timeout: 1000 }),
).rejects.toThrow();
});
});
describe("Setup Payment modal", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
afterEach(() => {
vi.resetAllMocks();
});
it("should only render if SaaS mode and is new user", async () => {
// @ts-expect-error - we only need the APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
},
});
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const setupPaymentModal = await screen.findByTestId(
"proceed-to-stripe-button",
);
expect(setupPaymentModal).toBeInTheDocument();
});
});
@@ -0,0 +1,91 @@
import { expect, test } from "vitest";
import {
SuggestedTask,
SuggestedTaskGroup,
} from "#/components/features/home/tasks/task.types";
import { groupSuggestedTasks } from "#/utils/group-suggested-tasks";
const rawTasks: SuggestedTask[] = [
{
issue_number: 1,
repo: "repo1",
title: "Task 1",
task_type: "MERGE_CONFLICTS",
},
{
issue_number: 2,
repo: "repo1",
title: "Task 2",
task_type: "FAILING_CHECKS",
},
{
issue_number: 3,
repo: "repo2",
title: "Task 3",
task_type: "UNRESOLVED_COMMENTS",
},
{
issue_number: 4,
repo: "repo2",
title: "Task 4",
task_type: "OPEN_ISSUE",
},
{
issue_number: 5,
repo: "repo3",
title: "Task 5",
task_type: "FAILING_CHECKS",
},
];
const groupedTasks: SuggestedTaskGroup[] = [
{
title: "repo1",
tasks: [
{
issue_number: 1,
repo: "repo1",
title: "Task 1",
task_type: "MERGE_CONFLICTS",
},
{
issue_number: 2,
repo: "repo1",
title: "Task 2",
task_type: "FAILING_CHECKS",
},
],
},
{
title: "repo2",
tasks: [
{
issue_number: 3,
repo: "repo2",
title: "Task 3",
task_type: "UNRESOLVED_COMMENTS",
},
{
issue_number: 4,
repo: "repo2",
title: "Task 4",
task_type: "OPEN_ISSUE",
},
],
},
{
title: "repo3",
tasks: [
{
issue_number: 5,
repo: "repo3",
title: "Task 5",
task_type: "FAILING_CHECKS",
},
],
},
];
test("groupSuggestedTasks", () => {
expect(groupSuggestedTasks(rawTasks)).toEqual(groupedTasks);
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.33.0",
"version": "0.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.33.0",
"version": "0.34.0",
"dependencies": {
"@heroui/react": "2.7.6",
"@microlink/react-json-view": "^1.26.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.33.0",
"version": "0.34.0",
"private": true,
"type": "module",
"engines": {
@@ -105,6 +105,7 @@ function isRawTranslationKey(str) {
// Specific technical strings that should be excluded from localization
const EXCLUDED_TECHNICAL_STRINGS = [
"openid email profile", // OAuth scope string - not user-facing
"OPEN_ISSUE", // Task type identifier, not a UI string
];
function isExcludedTechnicalString(str) {
@@ -0,0 +1,9 @@
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { openHands } from "../open-hands-axios";
export class SuggestionsService {
static async getSuggestedTasks(): Promise<SuggestedTask[]> {
const { data } = await openHands.get("/api/user/suggested-tasks");
return data;
}
}
@@ -15,13 +15,13 @@
fill="black" />
<path
d="M38.7381 10.5084C38.5759 10.5084 38.4106 10.4788 38.2545 10.4076C37.6821 10.1526 37.4312 9.49736 37.6944 8.94289C38.5453 7.1431 39.791 5.48266 41.2938 4.14245C41.7559 3.73031 42.4782 3.75699 42.9037 4.20768C43.3291 4.65541 43.3016 5.35516 42.8363 5.76731C41.5539 6.91182 40.4919 8.32912 39.7634 9.86502C39.5737 10.2653 39.1666 10.5055 38.7381 10.5084Z"
fill="white" />
fill="black" />
<path
d="M34.898 9.87074C34.3073 9.87667 33.8023 9.43784 33.7533 8.85669C33.536 6.25633 33.5268 3.62039 33.7319 1.02003C33.7808 0.412188 34.3287 -0.0414663 34.9531 0.00300963C35.5805 0.0504507 36.0488 0.578232 36.0029 1.18607C35.807 3.67079 35.8162 6.1911 36.0243 8.67582C36.0763 9.28366 35.6081 9.81737 34.9806 9.86481C34.9531 9.86481 34.9255 9.86778 34.898 9.86778V9.87074Z"
fill="white" />
fill="black" />
<path
d="M30.976 10.5558C30.4649 10.5618 29.9935 10.2267 29.8619 9.7256C29.3783 7.88726 28.4632 6.14084 27.2175 4.67906C26.8165 4.20762 26.8869 3.51379 27.3705 3.12537C27.8572 2.73695 28.5734 2.80514 28.9743 3.27362C30.4312 4.98743 31.5024 7.03036 32.0656 9.18003C32.2217 9.77008 31.8514 10.372 31.2423 10.5232C31.1505 10.5469 31.0617 10.5558 30.9699 10.5588L30.976 10.5558Z"
fill="white" />
fill="black" />
</g>
</g>
<defs>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,21 @@
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { BrandButton } from "#/components/features/settings/brand-button";
import { useSettings } from "#/hooks/query/use-settings";
export function ConnectToProviderMessage() {
const { isLoading } = useSettings();
const { t } = useTranslation();
return (
<div className="flex flex-col gap-4">
<p>{t("HOME$CONNECT_PROVIDER_MESSAGE")}</p>
<Link data-testid="navigate-to-settings-button" to="/settings">
<BrandButton type="button" variant="primary" isDisabled={isLoading}>
{!isLoading && t("SETTINGS$TITLE")}
{isLoading && t("HOME$LOADING")}
</BrandButton>
</Link>
</div>
);
}
@@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { BrandButton } from "../settings/brand-button";
import AllHandsLogo from "#/assets/branding/all-hands-logo-spark.svg?react";
export function HomeHeader() {
const {
mutate: createConversation,
isPending,
isSuccess,
} = useCreateConversation();
const isCreatingConversationElsewhere = useIsCreatingConversation();
const { t } = useTranslation();
// We check for isSuccess because the app might require time to render
// into the new conversation screen after the conversation is created.
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
return (
<header className="flex flex-col gap-5">
<AllHandsLogo />
<div className="flex items-center justify-between">
<h1 className="heading">{t("HOME$LETS_START_BUILDING")}</h1>
<BrandButton
testId="header-launch-button"
variant="primary"
type="button"
onClick={() => createConversation({})}
isDisabled={isCreatingConversation}
>
{!isCreatingConversation && "Launch from Scratch"}
{isCreatingConversation && t("HOME$LOADING")}
</BrandButton>
</div>
<div className="flex items-center justify-between">
<p className="text-sm max-w-[424px]">
{t("HOME$OPENHANDS_DESCRIPTION")}
</p>
<p className="text-sm">
{t("HOME$NOT_SURE_HOW_TO_START")}{" "}
<a
href="https://docs.all-hands.dev/modules/usage/getting-started"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
Read this
</a>
</p>
</div>
</header>
);
}
@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { ConnectToProviderMessage } from "./connect-to-provider-message";
import { useAuth } from "#/context/auth-context";
import { RepositorySelectionForm } from "./repo-selection-form";
import { useConfig } from "#/hooks/query/use-config";
import { RepoProviderLinks } from "./repo-provider-links";
interface RepoConnectorProps {
onRepoSelection: (repoTitle: string | null) => void;
}
export function RepoConnector({ onRepoSelection }: RepoConnectorProps) {
const { providersAreSet } = useAuth();
const { data: config } = useConfig();
const { t } = useTranslation();
const isSaaS = config?.APP_MODE === "saas";
return (
<section
data-testid="repo-connector"
className="w-full flex flex-col gap-6"
>
<h2 className="heading">{t("HOME$CONNECT_TO_REPOSITORY")}</h2>
{!providersAreSet && <ConnectToProviderMessage />}
{providersAreSet && (
<RepositorySelectionForm onRepoSelection={onRepoSelection} />
)}
{isSaaS && providersAreSet && <RepoProviderLinks />}
</section>
);
}
@@ -0,0 +1,17 @@
import { useConfig } from "#/hooks/query/use-config";
export function RepoProviderLinks() {
const { data: config } = useConfig();
const githubHref = config
? `https://github.com/apps/${config.APP_SLUG}/installations/new`
: "";
return (
<div className="flex flex-col text-sm underline underline-offset-2 text-content-2 gap-4 w-fit">
<a href={githubHref} target="_blank" rel="noopener noreferrer">
Add GitHub repos
</a>
</div>
);
}
@@ -0,0 +1,79 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { GitRepository } from "#/types/git";
import { BrandButton } from "../settings/brand-button";
import { SettingsDropdownInput } from "../settings/settings-dropdown-input";
interface RepositorySelectionFormProps {
onRepoSelection: (repoTitle: string | null) => void;
}
export function RepositorySelectionForm({
onRepoSelection,
}: RepositorySelectionFormProps) {
const [selectedRepository, setSelectedRepository] =
React.useState<GitRepository | null>(null);
const { data: repositories } = useUserRepositories();
const {
mutate: createConversation,
isPending,
isSuccess,
} = useCreateConversation();
const isCreatingConversationElsewhere = useIsCreatingConversation();
const { t } = useTranslation();
// We check for isSuccess because the app might require time to render
// into the new conversation screen after the conversation is created.
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
const repositoriesList = repositories?.pages.flatMap((page) => page.data);
const repositoriesItems = repositoriesList?.map((repo) => ({
key: repo.id,
label: repo.full_name,
}));
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = repositoriesList?.find(
(repo) => repo.id.toString() === key,
);
if (selectedRepo) onRepoSelection(selectedRepo.full_name);
setSelectedRepository(selectedRepo || null);
};
const handleInputChange = (value: string) => {
if (value === "") {
setSelectedRepository(null);
onRepoSelection(null);
}
};
return (
<>
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder="Select a repo"
items={repositoriesItems || []}
wrapperClassName="max-w-[500px]"
onSelectionChange={handleRepoSelection}
onInputChange={handleInputChange}
/>
<BrandButton
testId="repo-launch-button"
variant="primary"
type="button"
isDisabled={!selectedRepository || isCreatingConversation}
onClick={() => createConversation({ selectedRepository })}
>
{!isCreatingConversation && "Launch"}
{isCreatingConversation && t("HOME$LOADING")}
</BrandButton>
</>
);
}
@@ -0,0 +1,49 @@
import { SuggestedTaskType } from "./task.types";
export const getMergeConflictPrompt = (
issueNumber: number,
repo: string,
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the merge conflicts.
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.`;
export const getFailingChecksPrompt = (
issueNumber: number,
repo: string,
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the failing CI checks.
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
Then use the GitHub API to look at the GitHub Actions that are failing on the most recent commit. Try and reproduce the failure locally.
Get things working locally, then push your changes. Sleep for 30 seconds at a time until the GitHub actions have run again. If they are still failing, repeat the process.`;
export const getUnresolvedCommentsPrompt = (
issueNumber: number,
repo: string,
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to resolve the remaining comments from reviewers.
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
Then use the GitHub API to retrieve all the feedback on the PR so far. If anything hasn't been addressed, address it and commit your changes back to the same branch.`;
export const getOpenIssuePrompt = (
issueNumber: number,
repo: string,
) => `You are working on Issue #${issueNumber} in repository ${repo}. Your goal is to fix the issue
Use the GitHub API to retrieve the issue details and any comments on the issue. Then check out a new branch and investigate what changes will need to be made
Finally, make the required changes and open up a pull request. Be sure to reference the issue in the PR description`;
export const getPromptForQuery = (
type: SuggestedTaskType,
issueNumber: number,
repo: string,
) => {
switch (type) {
case "MERGE_CONFLICTS":
return getMergeConflictPrompt(issueNumber, repo);
case "FAILING_CHECKS":
return getFailingChecksPrompt(issueNumber, repo);
case "UNRESOLVED_COMMENTS":
return getUnresolvedCommentsPrompt(issueNumber, repo);
case "OPEN_ISSUE":
return getOpenIssuePrompt(issueNumber, repo);
default:
return "";
}
};
@@ -0,0 +1,79 @@
import { useTranslation } from "react-i18next";
import { SuggestedTask } from "./task.types";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { cn } from "#/utils/utils";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { getPromptForQuery } from "./get-prompt-for-query";
import { TaskIssueNumber } from "./task-issue-number";
const getTaskTypeMap = (
t: (key: string) => string,
): Record<SuggestedTask["task_type"], string> => ({
FAILING_CHECKS: t("HOME$FIX_FAILING_CHECKS"),
MERGE_CONFLICTS: t("HOME$RESOLVE_MERGE_CONFLICTS"),
OPEN_ISSUE: t("HOME$OPEN_ISSUE"),
UNRESOLVED_COMMENTS: t("HOME$RESOLVE_UNRESOLVED_COMMENTS"),
});
interface TaskCardProps {
task: SuggestedTask;
}
export function TaskCard({ task }: TaskCardProps) {
const { data: repositories } = useUserRepositories();
const { mutate: createConversation, isPending } = useCreateConversation();
const isCreatingConversation = useIsCreatingConversation();
const { t } = useTranslation();
const getRepo = (repo: string) => {
const repositoriesList = repositories?.pages.flatMap((page) => page.data);
const selectedRepo = repositoriesList?.find(
(repository) => repository.full_name === repo,
);
return selectedRepo;
};
const handleLaunchConversation = () => {
const repo = getRepo(task.repo);
const query = getPromptForQuery(
task.task_type,
task.issue_number,
task.repo,
);
return createConversation({
selectedRepository: repo,
q: query,
});
};
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
const href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;
return (
<li className="py-3 border-b border-[#717888] flex items-center pr-6">
<TaskIssueNumber issueNumber={task.issue_number} href={href} />
<div className="w-full pl-8">
<p className="font-semibold">{getTaskTypeMap(t)[task.task_type]}</p>
<p>{task.title}</p>
</div>
<button
type="button"
data-testid="task-launch-button"
className={cn(
"underline underline-offset-2 disabled:opacity-80",
isPending && "no-underline font-bold",
)}
disabled={isCreatingConversation}
onClick={handleLaunchConversation}
>
{!isPending && t("HOME$LAUNCH")}
{isPending && t("HOME$LOADING")}
</button>
</li>
);
}
@@ -0,0 +1,22 @@
import { TaskCard } from "./task-card";
import { TaskItemTitle } from "./task-item-title";
import { SuggestedTask } from "./task.types";
interface TaskGroupProps {
title: string;
tasks: SuggestedTask[];
}
export function TaskGroup({ title, tasks }: TaskGroupProps) {
return (
<div className="text-content-2">
<TaskItemTitle>{title}</TaskItemTitle>
<ul className="text-sm">
{tasks.map((task) => (
<TaskCard key={task.issue_number} task={task} />
))}
</ul>
</div>
);
}
@@ -0,0 +1,17 @@
interface TaskIssueNumberProps {
issueNumber: number;
href: string;
}
export function TaskIssueNumber({ href, issueNumber }: TaskIssueNumberProps) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
data-testid="task-id"
>
#<span className="underline underline-offset-2">{issueNumber}</span>
</a>
);
}
@@ -0,0 +1,7 @@
export function TaskItemTitle({ children: title }: React.PropsWithChildren) {
return (
<div className="py-3 border-b-1 border-[#717888]">
<h3 className="text-[16px] leading-6 font-[500]">{title}</h3>
</div>
);
}
@@ -0,0 +1,50 @@
import { cn } from "#/utils/utils";
const VALID_WIDTHS = ["w-1/4", "w-1/2", "w-3/4"];
const getRandomWidth = () =>
VALID_WIDTHS[Math.floor(Math.random() * VALID_WIDTHS.length)];
const getRandomNumber = (from = 3, to = 5) =>
Math.floor(Math.random() * (to - from + 1)) + from;
function TaskCardSkeleton() {
return (
<li className="py-3 border-b border-[#717888] flex items-center pr-6">
<div className="h-5 w-8 skeleton" />
<div className="w-full pl-8">
<div className="h-5 w-24 skeleton mb-2" />
<div className={cn("h-5 skeleton", getRandomWidth())} />
</div>
<div className="h-5 w-16 skeleton" />
</li>
);
}
interface TaskGroupSkeletonProps {
items?: number;
}
function TaskGroupSkeleton({ items = 3 }: TaskGroupSkeletonProps) {
return (
<div data-testid="task-group-skeleton">
<div className="py-3 border-b border-[#717888]">
<div className="h-6 w-40 skeleton" />
</div>
<ul>
{Array.from({ length: items }).map((_, index) => (
<TaskCardSkeleton key={index} />
))}
</ul>
</div>
);
}
export function TaskSuggestionsSkeleton() {
return Array.from({ length: getRandomNumber(2, 3) }).map((_, index) => (
<TaskGroupSkeleton key={index} items={getRandomNumber(3, 5)} />
));
}
@@ -0,0 +1,43 @@
import { TaskGroup } from "./task-group";
import { useSuggestedTasks } from "#/hooks/query/use-suggested-tasks";
import { TaskSuggestionsSkeleton } from "./task-suggestions-skeleton";
import { useAuth } from "#/context/auth-context";
import { cn } from "#/utils/utils";
import { ConnectToProviderMessage } from "../connect-to-provider-message";
interface TaskSuggestionsProps {
filterFor?: string | null;
}
export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) {
const { providersAreSet } = useAuth();
const { data: tasks, isLoading } = useSuggestedTasks();
const suggestedTasks = filterFor
? tasks?.filter((task) => task.title === filterFor)
: tasks;
const hasSuggestedTasks = suggestedTasks && suggestedTasks.length > 0;
return (
<section
data-testid="task-suggestions"
className={cn("flex flex-col w-full", !hasSuggestedTasks && "gap-6")}
>
<h2 className="heading">Suggested Tasks</h2>
<div className="flex flex-col gap-6">
{!providersAreSet && <ConnectToProviderMessage />}
{isLoading && <TaskSuggestionsSkeleton />}
{!hasSuggestedTasks && !isLoading && <p>No tasks available</p>}
{suggestedTasks?.map((taskGroup, index) => (
<TaskGroup
key={index}
title={taskGroup.title}
tasks={taskGroup.tasks}
/>
))}
</div>
</section>
);
}
@@ -0,0 +1,17 @@
export type SuggestedTaskType =
| "MERGE_CONFLICTS"
| "FAILING_CHECKS"
| "UNRESOLVED_COMMENTS"
| "OPEN_ISSUE"; // This is a task type identifier, not a UI string
export interface SuggestedTask {
issue_number: number;
repo: string;
title: string;
task_type: SuggestedTaskType;
}
export interface SuggestedTaskGroup {
title: string;
tasks: SuggestedTask[];
}
@@ -29,7 +29,7 @@ export function BrandButton({
type={type}
onClick={onClick}
className={cn(
"w-fit p-2 rounded disabled:opacity-30 disabled:cursor-not-allowed",
"w-fit p-2 text-sm rounded disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80",
variant === "primary" && "bg-primary text-[#0D0F11]",
variant === "secondary" && "border border-primary text-primary",
startContent && "flex items-center justify-center gap-2",
@@ -1,42 +1,56 @@
import { Autocomplete, AutocompleteItem } from "@heroui/react";
import { ReactNode } from "react";
import { OptionalTag } from "./optional-tag";
import { cn } from "#/utils/utils";
interface SettingsDropdownInputProps {
testId: string;
label: ReactNode;
name: string;
items: { key: React.Key; label: string }[];
label?: ReactNode;
wrapperClassName?: string;
placeholder?: string;
showOptionalTag?: boolean;
isDisabled?: boolean;
defaultSelectedKey?: string;
isClearable?: boolean;
onSelectionChange?: (key: React.Key | null) => void;
onInputChange?: (value: string) => void;
}
export function SettingsDropdownInput({
testId,
label,
wrapperClassName,
name,
items,
placeholder,
showOptionalTag,
isDisabled,
defaultSelectedKey,
isClearable,
onSelectionChange,
onInputChange,
}: SettingsDropdownInputProps) {
return (
<label className="flex flex-col gap-2.5 w-[680px]">
<div className="flex items-center gap-1">
<span className="text-sm">{label}</span>
{showOptionalTag && <OptionalTag />}
</div>
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
{label && (
<div className="flex items-center gap-1">
<span className="text-sm">{label}</span>
{showOptionalTag && <OptionalTag />}
</div>
)}
<Autocomplete
aria-label={typeof label === "string" ? label : name}
data-testid={testId}
name={name}
defaultItems={items}
defaultSelectedKey={defaultSelectedKey}
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
isClearable={isClearable}
isDisabled={isDisabled}
placeholder={placeholder}
className="w-full"
classNames={{
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",
+5 -1
View File
@@ -10,6 +10,7 @@ interface AuthContextType {
interface AuthContextProps extends React.PropsWithChildren {
initialProviderTokens?: Provider[];
initialProvidersAreSet?: boolean;
}
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
@@ -17,12 +18,15 @@ const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
function AuthProvider({
children,
initialProviderTokens = [],
initialProvidersAreSet = false,
}: AuthContextProps) {
const [providerTokensSet, setProviderTokensSet] = React.useState<Provider[]>(
initialProviderTokens,
);
const [providersAreSet, setProvidersAreSet] = React.useState<boolean>(false);
const [providersAreSet, setProvidersAreSet] = React.useState<boolean>(
initialProvidersAreSet,
);
const value = React.useMemo(
() => ({
@@ -5,6 +5,7 @@ import { useDispatch, useSelector } from "react-redux";
import OpenHands from "#/api/open-hands";
import { setInitialPrompt } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { GitRepository } from "#/types/git";
export const useCreateConversation = () => {
const navigate = useNavigate();
@@ -16,11 +17,15 @@ export const useCreateConversation = () => {
);
return useMutation({
mutationFn: async (variables: { q?: string }) => {
mutationKey: ["create-conversation"],
mutationFn: async (variables: {
q?: string;
selectedRepository?: GitRepository | null;
}) => {
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(
selectedRepository || undefined,
variables.selectedRepository || undefined,
variables.q,
files,
replayJson || undefined,
@@ -29,5 +29,10 @@ export const useLogout = () => {
navigate("/");
window.location.reload();
},
onSuccess: () => {
// Home screen suggested tasks
queryClient.invalidateQueries({ queryKey: ["tasks"] });
queryClient.removeQueries({ queryKey: ["tasks"] });
},
});
};
@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
import { groupSuggestedTasks } from "#/utils/group-suggested-tasks";
import { useAuth } from "#/context/auth-context";
export const useSuggestedTasks = () => {
const { providersAreSet } = useAuth();
return useQuery({
queryKey: ["tasks"],
queryFn: SuggestionsService.getSuggestedTasks,
select: groupSuggestedTasks,
enabled: providersAreSet,
});
};
@@ -0,0 +1,14 @@
import { useIsMutating } from "@tanstack/react-query";
import { useNavigation } from "react-router";
export const useIsCreatingConversation = () => {
const navigation = useNavigation();
const numberOfPendingMutations = useIsMutating({
mutationKey: ["create-conversation"],
});
const isNavigating = Boolean(navigation.location);
const hasPendingMutations = numberOfPendingMutations > 0;
return hasPendingMutations || isNavigating;
};
+11
View File
@@ -1,5 +1,16 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
HOME$CONNECT_PROVIDER_MESSAGE = "HOME$CONNECT_PROVIDER_MESSAGE",
HOME$LETS_START_BUILDING = "HOME$LETS_START_BUILDING",
HOME$OPENHANDS_DESCRIPTION = "HOME$OPENHANDS_DESCRIPTION",
HOME$NOT_SURE_HOW_TO_START = "HOME$NOT_SURE_HOW_TO_START",
HOME$CONNECT_TO_REPOSITORY = "HOME$CONNECT_TO_REPOSITORY",
HOME$LOADING = "HOME$LOADING",
HOME$OPEN_ISSUE = "HOME$OPEN_ISSUE",
HOME$FIX_FAILING_CHECKS = "HOME$FIX_FAILING_CHECKS",
HOME$RESOLVE_MERGE_CONFLICTS = "HOME$RESOLVE_MERGE_CONFLICTS",
HOME$RESOLVE_UNRESOLVED_COMMENTS = "HOME$RESOLVE_UNRESOLVED_COMMENTS",
HOME$LAUNCH = "HOME$LAUNCH",
CHAT$RESOLVER_INSTRUCTIONS = "CHAT$RESOLVER_INSTRUCTIONS",
SETTINGS$ADVANCED = "SETTINGS$ADVANCED",
SETTINGS$BASE_URL = "SETTINGS$BASE_URL",
+165
View File
@@ -1,4 +1,169 @@
{
"HOME$CONNECT_PROVIDER_MESSAGE": {
"en": "To get started with suggested tasks, please connect your GitHub or GitLab account.",
"ja": "提案されたタスクを始めるには、GitHubまたはGitLabアカウントを接続してください。",
"zh-CN": "要开始使用建议的任务,请连接您的GitHub或GitLab账户。",
"zh-TW": "要開始使用建議的任務,請連接您的GitHub或GitLab帳戶。",
"ko-KR": "제안된 작업을 시작하려면 GitHub 또는 GitLab 계정을 연결하세요.",
"no": "For å komme i gang med foreslåtte oppgaver, vennligst koble til GitHub eller GitLab-kontoen din.",
"it": "Per iniziare con le attività suggerite, collega il tuo account GitHub o GitLab.",
"pt": "Para começar com tarefas sugeridas, conecte sua conta GitHub ou GitLab.",
"es": "Para comenzar con las tareas sugeridas, conecte su cuenta de GitHub o GitLab.",
"ar": "للبدء بالمهام المقترحة، يرجى ربط حساب GitHub أو GitLab الخاص بك.",
"fr": "Pour commencer avec les tâches suggérées, veuillez connecter votre compte GitHub ou GitLab.",
"tr": "Önerilen görevlerle başlamak için lütfen GitHub veya GitLab hesabınızı bağlayın.",
"de": "Um mit vorgeschlagenen Aufgaben zu beginnen, verbinden Sie bitte Ihr GitHub- oder GitLab-Konto."
},
"HOME$LETS_START_BUILDING": {
"en": "Let's Start Building!",
"ja": "構築を始めましょう!",
"zh-CN": "让我们开始构建!",
"zh-TW": "讓我們開始構建!",
"ko-KR": "구축을 시작합시다!",
"no": "La oss begynne å bygge!",
"it": "Iniziamo a costruire!",
"pt": "Vamos começar a construir!",
"es": "¡Comencemos a construir!",
"ar": "لنبدأ البناء!",
"fr": "Commençons à construire !",
"tr": "Hadi İnşa Etmeye Başlayalım!",
"de": "Lass uns anfangen zu bauen!"
},
"HOME$OPENHANDS_DESCRIPTION": {
"en": "OpenHands makes it easy to build and maintain software using AI-driven development.",
"ja": "OpenHandsはAI駆動の開発を使用してソフトウェアの構築と維持を容易にします。",
"zh-CN": "OpenHands使用AI驱动的开发方式,轻松构建和维护软件。",
"zh-TW": "OpenHands使用AI驅動的開發方式,輕鬆構建和維護軟件。",
"ko-KR": "OpenHands는 AI 기반 개발을 사용하여 소프트웨어를 쉽게 구축하고 유지할 수 있게 합니다.",
"no": "OpenHands gjør det enkelt å bygge og vedlikeholde programvare ved hjelp av AI-drevet utvikling.",
"it": "OpenHands rende facile costruire e mantenere software utilizzando lo sviluppo guidato dall'IA.",
"pt": "OpenHands facilita a construção e manutenção de software usando desenvolvimento orientado por IA.",
"es": "OpenHands facilita la construcción y el mantenimiento de software utilizando desarrollo impulsado por IA.",
"ar": "يجعل OpenHands من السهل بناء وصيانة البرمجيات باستخدام التطوير المدعوم بالذكاء الاصطناعي.",
"fr": "OpenHands facilite la création et la maintenance de logiciels grâce au développement piloté par l'IA.",
"tr": "OpenHands, yapay zeka destekli geliştirme kullanarak yazılım oluşturmayı ve sürdürmeyi kolaylaştırır.",
"de": "OpenHands macht es einfach, Software mit KI-gesteuerter Entwicklung zu erstellen und zu warten."
},
"HOME$NOT_SURE_HOW_TO_START": {
"en": "Not sure how to start?",
"ja": "始め方がわからない?",
"zh-CN": "不确定如何开始?",
"zh-TW": "不確定如何開始?",
"ko-KR": "시작 방법을 모르시나요?",
"no": "Usikker på hvordan du skal starte?",
"it": "Non sei sicuro di come iniziare?",
"pt": "Não tem certeza de como começar?",
"es": "¿No está seguro de cómo empezar?",
"ar": "غير متأكد من كيفية البدء؟",
"fr": "Vous ne savez pas par où commencer ?",
"tr": "Nasıl başlayacağınızdan emin değil misiniz?",
"de": "Nicht sicher, wie man anfängt?"
},
"HOME$CONNECT_TO_REPOSITORY": {
"en": "Connect to a Repository",
"ja": "リポジトリに接続",
"zh-CN": "连接到仓库",
"zh-TW": "連接到存儲庫",
"ko-KR": "저장소에 연결",
"no": "Koble til et repository",
"it": "Connetti a un repository",
"pt": "Conectar a um repositório",
"es": "Conectar a un repositorio",
"ar": "الاتصال بمستودع",
"fr": "Se connecter à un dépôt",
"tr": "Bir Depoya Bağlan",
"de": "Mit einem Repository verbinden"
},
"HOME$LOADING": {
"en": "Loading...",
"ja": "読み込み中...",
"zh-CN": "加载中...",
"zh-TW": "載入中...",
"ko-KR": "로딩 중...",
"no": "Laster...",
"it": "Caricamento in corso...",
"pt": "Carregando...",
"es": "Cargando...",
"ar": "جار التحميل...",
"fr": "Chargement...",
"tr": "Yükleniyor...",
"de": "Wird geladen..."
},
"HOME$OPEN_ISSUE": {
"en": "Open issue",
"ja": "オープンな課題",
"zh-CN": "打开问题",
"zh-TW": "開放議題",
"ko-KR": "열린 이슈",
"no": "Åpent problem",
"it": "Problema aperto",
"pt": "Problema aberto",
"es": "Problema abierto",
"ar": "مشكلة مفتوحة",
"fr": "Problème ouvert",
"tr": "Açık sorun",
"de": "Offenes Problem"
},
"HOME$FIX_FAILING_CHECKS": {
"en": "Fix failing checks",
"ja": "失敗したチェックを修正",
"zh-CN": "修复失败的检查",
"zh-TW": "修復失敗的檢查",
"ko-KR": "실패한 검사 수정",
"no": "Fikse mislykkede kontroller",
"it": "Correggere i controlli falliti",
"pt": "Corrigir verificações com falha",
"es": "Corregir comprobaciones fallidas",
"ar": "إصلاح الفحوصات الفاشلة",
"fr": "Corriger les vérifications échouées",
"tr": "Başarısız kontrolleri düzelt",
"de": "Fehlgeschlagene Prüfungen beheben"
},
"HOME$RESOLVE_MERGE_CONFLICTS": {
"en": "Resolve merge conflicts",
"ja": "マージ競合を解決",
"zh-CN": "解决合并冲突",
"zh-TW": "解決合併衝突",
"ko-KR": "병합 충돌 해결",
"no": "Løse fletteproblemer",
"it": "Risolvere i conflitti di unione",
"pt": "Resolver conflitos de mesclagem",
"es": "Resolver conflictos de fusión",
"ar": "حل تعارضات الدمج",
"fr": "Résoudre les conflits de fusion",
"tr": "Birleştirme çakışmalarını çöz",
"de": "Merge-Konflikte lösen"
},
"HOME$RESOLVE_UNRESOLVED_COMMENTS": {
"en": "Resolve unresolved comments",
"ja": "未解決のコメントを解決",
"zh-CN": "解决未解决的评论",
"zh-TW": "解決未解決的評論",
"ko-KR": "미해결 댓글 해결",
"no": "Løse uløste kommentarer",
"it": "Risolvere i commenti non risolti",
"pt": "Resolver comentários não resolvidos",
"es": "Resolver comentarios no resueltos",
"ar": "حل التعليقات غير المحلولة",
"fr": "Résoudre les commentaires non résolus",
"tr": "Çözülmemiş yorumları çöz",
"de": "Ungelöste Kommentare beheben"
},
"HOME$LAUNCH": {
"en": "Launch",
"ja": "起動",
"zh-CN": "启动",
"zh-TW": "啟動",
"ko-KR": "실행",
"no": "Start",
"it": "Avvia",
"pt": "Iniciar",
"es": "Iniciar",
"ar": "تشغيل",
"fr": "Lancer",
"tr": "Başlat",
"de": "Starten"
},
"CHAT$RESOLVER_INSTRUCTIONS": {
"en": "Resolver Instructions",
"ja": "リゾルバの指示",
+18
View File
@@ -0,0 +1,18 @@
<svg width="102" height="69" viewBox="0 0 102 69" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_40000615_2266)">
<g clip-path="url(#clip1_40000615_2266)">
<path d="M97.9659 20.9744C94.7109 18.9968 92.5363 22.0276 92.8031 26.1428L92.7759 26.1739C92.785 21.8765 92.1973 17.1303 90.2443 13.2906C89.5526 11.9307 88.1511 9.69537 85.3798 10.7486C84.1637 11.2108 83.0606 12.6018 83.6302 16.1926C83.6302 16.1926 84.2631 19.9611 84.1456 24.6985V24.7651C83.3544 11.6996 80.3707 7.71333 76.103 7.97108C74.7376 8.21106 72.8705 8.79323 73.4989 12.8107C73.4989 12.8107 74.1816 17.0014 74.4031 20.3389L74.4167 20.5077H74.4031C72.3958 13.3039 69.6924 13.2062 67.7348 13.4861C65.9581 13.7395 64.0187 15.5615 64.9997 19.1523C68.0784 30.418 67.4771 43.9857 67.2466 45.9277C66.6182 44.5989 66.4238 43.5457 65.5512 42.088C62.043 36.2352 60.3748 35.8042 58.3269 35.7153C56.2925 35.6264 54.0953 36.8663 54.24 39.2261C54.3892 41.5859 55.6053 41.9769 57.3323 45.2655C58.6795 47.8253 59.0638 51.1806 61.7763 57.2778C64.0232 62.3262 69.8958 67.8635 80.5922 67.2058C89.2587 66.9214 102.202 63.9261 99.9506 44.2567C99.39 40.8393 99.8104 37.9773 100.104 35.0442C100.561 30.4935 101.23 22.952 97.9704 20.9699L97.9659 20.9744Z" fill="#E3D495"/>
<path d="M43.8511 35.8532C41.8032 35.9821 40.1485 36.4398 36.7488 42.3548C35.9034 43.8258 35.7316 44.8835 35.1258 46.2212C34.8591 44.2835 34.0046 30.7292 36.8709 19.4102C37.7841 15.8061 35.813 14.0196 34.0318 13.7974C32.0697 13.553 29.3617 13.6996 27.4901 20.9745H27.4675L27.4946 20.7656C27.6528 17.4237 28.2586 13.2241 28.2586 13.2241C28.8056 9.19334 26.934 8.64672 25.5642 8.42896C21.3055 8.2512 18.4031 12.2553 17.8425 25.2052H17.8335C17.6391 20.5168 18.1951 16.7838 18.1951 16.7838C18.697 13.1797 17.5667 11.8109 16.3416 11.3709C13.5522 10.3666 12.1914 12.6286 11.5268 14.0018C9.64616 17.877 9.14434 22.6321 9.23476 26.9295L9.20763 26.8984C9.39299 22.7788 7.1642 19.788 3.94533 21.8233C0.726467 23.8631 1.5357 31.3914 2.07821 35.9332C2.43084 38.8618 2.90101 41.7149 2.40371 45.1413C0.523028 64.8462 13.5205 67.6059 22.1916 67.7348C32.897 68.2014 38.6657 62.553 40.8176 57.4646C43.4126 51.3229 43.7381 47.9588 45.0356 45.3768C46.6993 42.0571 47.9109 41.6438 48.0148 39.284C48.1188 36.9242 45.8991 35.7243 43.8647 35.8487L43.8511 35.8532Z" fill="#E3D495"/>
<path d="M48.1374 35.8835C47.0524 34.8392 45.4204 34.2837 43.7612 34.3815C41.1029 34.5459 39.2177 35.5014 36.3198 40.1676C36.243 34.8303 36.5459 26.8177 38.3271 19.7694C38.9962 17.1208 38.3226 15.392 37.6354 14.4099C36.8352 13.2633 35.592 12.5078 34.2176 12.339C32.9653 12.1834 31.3288 12.1745 29.7329 13.4633C29.7329 13.4455 29.7374 13.4233 29.7374 13.4233C30.2528 9.63699 28.9237 7.4683 25.7907 6.97945L25.6189 6.96167C23.6885 6.87724 22.0339 7.4994 20.7002 8.80595C19.9859 9.50367 19.3665 10.4058 18.8286 11.5213C18.2273 10.6547 17.4542 10.2147 16.8439 9.99251C13.1142 8.64597 11.0527 11.5302 10.1575 13.37C9.11772 15.512 8.47123 17.9118 8.10956 20.3605C8.03271 20.3116 7.96037 20.2627 7.88352 20.2183C7.06072 19.7516 5.34731 19.1828 3.12304 20.5916C-0.647376 22.9825 -0.168163 29.853 0.582303 36.1102C0.622991 36.4435 0.663679 36.7724 0.704367 37.1057C1.02535 39.6699 1.32825 42.0919 0.912327 44.9405L0.903285 45.0116C0.148299 52.9176 1.66279 59.0015 5.40608 63.1033C9.0047 67.0497 14.6422 69.1028 22.1152 69.2139C22.6623 69.2361 23.1957 69.245 23.7156 69.2406C36.4826 69.125 41.0622 60.7036 42.1879 58.0371C43.6437 54.5885 44.3986 52.0154 44.9999 49.9445C45.4656 48.3447 45.8363 47.0825 46.3607 46.0337C46.9575 44.8427 47.4819 44.0695 47.943 43.3851C48.7296 42.2208 49.4078 41.2164 49.4892 39.3499C49.5479 37.9989 49.0777 36.799 48.1238 35.8835H48.1374ZM22.8205 10.8947C23.5393 10.1925 24.3666 9.88141 25.4155 9.90363C26.3242 10.0503 27.147 10.268 26.7717 13.0234C26.7446 13.1967 26.1569 17.3341 25.9987 20.7027C25.9987 20.7249 25.9987 20.7471 25.9987 20.7693C25.1714 24.0579 24.4797 28.7953 24.1089 35.7191C22.504 35.8169 20.9036 35.9858 19.3485 36.208C18.8466 22.1603 20.0085 13.6411 22.816 10.8947H22.8205ZM12.87 14.641C13.9957 12.3301 14.9406 12.4456 15.8177 12.7612C17.0338 13.2011 16.8439 15.5876 16.7038 16.5786C16.6811 16.7386 16.1386 20.4583 16.3285 25.1912C16.1884 28.5109 16.2065 32.3372 16.3737 36.7146C14.8864 37.0034 13.4758 37.3367 12.1874 37.6967C11.5771 35.6791 8.87811 22.8625 12.87 14.6454V14.641ZM45.4565 41.7541C44.9683 42.4741 44.3625 43.3718 43.6798 44.7316C43.0333 46.016 42.6355 47.3936 42.1246 49.1313C41.5414 51.1266 40.8136 53.6109 39.4257 56.9039C38.4401 59.2326 34.299 66.7963 22.2057 66.2631C15.465 66.1653 10.7 64.4854 7.63939 61.1302C4.48382 57.6727 3.2225 52.3487 3.88706 45.3138C4.34819 42.0963 4.00913 39.3721 3.6791 36.7412C3.63842 36.4124 3.59773 36.088 3.55704 35.7591C3.19537 32.7238 2.2279 24.6579 4.74603 23.0625C5.42416 22.6314 5.98023 22.5336 6.39163 22.7647C7.09237 23.1602 7.79762 24.6001 7.69816 26.831C7.69364 26.951 7.7072 27.0666 7.72981 27.1821C7.85639 32.3816 8.80126 36.9057 9.33924 38.6033C8.45767 38.9277 7.68008 39.2655 7.02907 39.6032C6.29669 39.9854 6.02092 40.8742 6.40971 41.5941C6.68097 42.0963 7.20539 42.3807 7.74789 42.3763C7.97846 42.3763 8.21354 42.3185 8.43507 42.203C12.0925 40.2965 20.768 38.3589 28.4128 38.5677C29.2447 38.5811 29.9273 37.95 29.9499 37.1368C29.9725 36.3235 29.3215 35.648 28.4942 35.6258C28.0376 35.6125 27.5765 35.6125 27.1153 35.6125C27.8794 21.6493 29.9816 17.0941 31.6181 15.7476C32.2963 15.1921 32.9427 15.1476 33.8379 15.2587C34.0865 15.2898 34.7104 15.432 35.158 16.072C35.6417 16.7697 35.7321 17.8007 35.4157 19.0539C32.6489 29.9907 33.2728 42.8518 33.5892 45.9715C33.535 46.0782 33.4852 46.1848 33.4265 46.2959C32.78 47.4914 31.5774 48.7357 30.1443 48.6513C29.326 48.6113 28.6072 49.2246 28.5575 50.0334C28.5078 50.8467 29.1362 51.5444 29.9635 51.5933C32.3867 51.7355 34.6697 50.2734 36.0712 47.6825C36.2204 47.407 36.347 47.1448 36.46 46.8914C36.469 46.8737 36.4781 46.8515 36.4871 46.8337C36.7493 46.256 36.9392 45.7271 37.1065 45.2516C37.3687 44.5139 37.5947 43.8739 38.0468 43.0829C41.2566 37.4923 42.5586 37.4123 43.9375 37.3279C44.7468 37.279 45.5515 37.5323 46.0262 37.9945C46.3652 38.3189 46.5144 38.7233 46.4918 39.2299C46.4466 40.2743 46.1527 40.7098 45.443 41.7586L45.4565 41.7541Z" fill="#0D0F11"/>
<path d="M101.42 44.0245C100.954 41.1848 101.212 38.7583 101.483 36.1896C101.519 35.8563 101.556 35.5275 101.587 35.1942C102.22 28.9281 102.573 22.0442 98.7526 19.7244C96.5013 18.3557 94.7969 18.9556 93.9831 19.4356C93.9063 19.48 93.8339 19.5333 93.7571 19.5822C93.3457 17.1424 92.6585 14.756 91.578 12.6317C90.6512 10.8097 88.54 7.96104 84.8329 9.37425C84.2271 9.60534 83.4676 10.0586 82.8798 10.9386C82.3193 9.83198 81.6818 8.94317 80.954 8.25879C79.5977 6.9789 77.9295 6.3834 76.0036 6.50339L75.8318 6.52117C72.7079 7.06779 71.4194 9.2587 72.0071 13.045C72.0071 13.045 72.0071 13.0628 72.0117 13.0761C70.3932 11.814 68.7566 11.854 67.5089 12.0318C66.139 12.2273 64.9094 13.005 64.1318 14.1649C63.4672 15.1604 62.8207 16.898 63.5395 19.5333C65.4564 26.5505 65.9085 34.5587 65.9311 39.896C62.9473 35.2831 61.044 34.3631 58.3857 34.2476C56.7311 34.1765 55.0991 34.7675 54.0366 35.8297C53.1008 36.7629 52.6533 37.9717 52.7392 39.3182C52.8567 41.1803 53.5529 42.1758 54.3622 43.3223C54.8368 43.9978 55.3748 44.7622 55.9942 45.9399C56.5412 46.9798 56.9345 48.233 57.4318 49.824C58.0738 51.8816 58.874 54.4413 60.3975 57.8633C61.573 60.5075 66.3108 68.849 79.0416 68.729C79.557 68.7245 80.0905 68.7067 80.633 68.6712C88.1467 68.4268 93.7435 66.267 97.2698 62.2584C100.932 58.0899 102.333 51.9793 101.434 44.0867L101.424 44.0156L101.42 44.0245ZM85.1041 15.9692C84.9459 14.9649 84.7063 12.5829 85.9179 12.1251C86.7859 11.7918 87.7353 11.6629 88.9017 13.9516C93.0473 22.0976 90.5925 34.9631 90.0183 36.9896C88.7208 36.6518 87.3058 36.3452 85.8139 36.083C85.8953 31.7056 85.841 27.8748 85.6421 24.5596C85.7416 19.8266 85.1312 16.1159 85.1041 15.9692ZM76.2658 9.44091C77.3192 9.39647 78.151 9.69422 78.8789 10.3875C81.7361 13.085 83.0607 21.5776 82.8211 35.6341C81.2614 35.4386 79.661 35.3008 78.0515 35.2297C77.5543 28.3103 76.7721 23.5908 75.8815 20.3155C75.8815 20.2933 75.8815 20.2711 75.8815 20.2488C75.66 16.8802 74.9909 12.7562 74.9638 12.5962C74.5343 9.83643 75.3526 9.60534 76.2613 9.44091H76.2658ZM98.4588 44.46C99.2545 51.4816 98.0926 56.8278 95.0048 60.343C92.0075 63.7516 87.2741 65.5204 80.4928 65.7426C68.4673 66.4892 64.1725 59.0054 63.1462 56.6945C61.6905 53.4237 60.9174 50.9572 60.2981 48.9707C59.7556 47.2375 59.3306 45.8732 58.6615 44.5978C57.9562 43.2512 57.3324 42.3669 56.8306 41.6558C56.1027 40.6204 55.7998 40.1893 55.732 39.1449C55.7003 38.6383 55.845 38.2295 56.175 37.9006C56.6452 37.4295 57.4409 37.1584 58.2546 37.1984C59.6335 37.2607 60.9355 37.314 64.2538 42.8468C64.724 43.629 64.9591 44.2645 65.2349 44.9977C65.4157 45.4733 65.6146 46.0021 65.8904 46.5754C65.8994 46.5932 65.904 46.6109 65.913 46.6243C66.0351 46.8776 66.1662 47.1353 66.3199 47.4109C67.7711 49.9751 70.0812 51.3972 72.4999 51.2105C73.3227 51.1483 73.9421 50.4373 73.8788 49.6284C73.8155 48.8196 73.0967 48.2197 72.2693 48.273C70.8362 48.3797 69.6111 47.1576 68.942 45.9754C68.8787 45.8643 68.829 45.7621 68.7747 45.6555C69.0324 42.5357 69.4167 29.6613 66.4419 18.7779C66.0984 17.5291 66.1707 16.4981 66.6409 15.7915C67.0794 15.1426 67.6987 14.9871 67.9474 14.9515C68.838 14.8227 69.489 14.8582 70.1762 15.4004C71.8399 16.7203 74.028 21.2354 75.0497 35.1808C74.5886 35.1853 74.1274 35.1986 73.6753 35.2208C72.848 35.2564 72.2106 35.9452 72.2467 36.7585C72.2829 37.5717 72.9701 38.185 73.811 38.1628C81.4467 37.8162 90.163 39.5938 93.852 41.4381C94.0735 41.5492 94.3086 41.598 94.5437 41.598C95.0862 41.5936 95.6061 41.3003 95.8683 40.7892C96.2436 40.0649 95.9497 39.176 95.2083 38.8072C94.5528 38.4783 93.7661 38.1584 92.88 37.8473C93.3864 36.1408 94.2498 31.5989 94.277 26.3994C94.2996 26.2839 94.3086 26.1683 94.2996 26.0483C94.1549 23.8218 94.8376 22.3686 95.5293 21.9598C95.9361 21.7198 96.4922 21.8087 97.1794 22.2264C99.7292 23.7774 98.9154 31.8567 98.608 34.9009C98.5763 35.2297 98.5402 35.5541 98.504 35.883C98.2237 38.5228 97.9344 41.247 98.4588 44.46Z" fill="#0D0F11"/>
</g>
</g>
<defs>
<clipPath id="clip0_40000615_2266">
<rect width="101.919" height="68.945" fill="white" transform="translate(0.0621948 0.29541)"/>
</clipPath>
<clipPath id="clip1_40000615_2266">
<rect width="101.919" height="68.945" fill="white" transform="translate(0.0621948 0.29541)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

+24 -8
View File
@@ -8,7 +8,8 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
import { GitUser } from "#/types/git";
import { GitRepository, GitUser } from "#/types/git";
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
@@ -105,13 +106,26 @@ const openHandsHandlers = [
export const handlers = [
...STRIPE_BILLING_HANDLERS,
...FILE_SERVICE_HANDLERS,
...TASK_SUGGESTIONS_HANDLERS,
...openHandsHandlers,
http.get("/api/user/repositories", () =>
HttpResponse.json([
{ id: 1, full_name: "octocat/hello-world" },
{ id: 2, full_name: "octocat/earth" },
]),
),
http.get("/api/user/repositories", () => {
const data: GitRepository[] = [
{
id: 1,
full_name: "octocat/hello-world",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "octocat/earth",
git_provider: "github",
is_public: true,
},
];
return HttpResponse.json(data);
}),
http.get("/api/user/info", () => {
const user: GitUser = {
id: 1,
@@ -231,7 +245,9 @@ export const handlers = [
},
),
http.post("/api/conversations", () => {
http.post("/api/conversations", async () => {
await delay();
const conversation: Conversation = {
conversation_id: (Math.random() * 100).toString(),
title: "New Conversation",
@@ -0,0 +1,76 @@
import { http, HttpResponse } from "msw";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
const TASKS_1: SuggestedTask[] = [
{
issue_number: 6968,
title: "Fix merge conflicts",
repo: "octocat/hello-world",
task_type: "MERGE_CONFLICTS",
},
];
const TASKS_2: SuggestedTask[] = [
{
issue_number: 268,
title: "Fix broken CI checks",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
{
issue_number: 281,
title: "Fix issue",
repo: "octocat/earth",
task_type: "UNRESOLVED_COMMENTS",
},
{
issue_number: 293,
title: "Update documentation",
repo: "octocat/earth",
task_type: "OPEN_ISSUE",
},
{
issue_number: 305,
title: "Refactor user service",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
{
issue_number: 312,
title: "Fix styling bug",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
{
issue_number: 327,
title: "Add unit tests",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
{
issue_number: 331,
title: "Implement dark mode",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
{
issue_number: 345,
title: "Optimize build process",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
{
issue_number: 352,
title: "Update dependencies",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
];
export const MOCK_TASKS = [...TASKS_1, ...TASKS_2];
export const TASK_SUGGESTIONS_HANDLERS = [
http.get("/api/user/suggested-tasks", async () =>
HttpResponse.json(MOCK_TASKS),
),
];
+2
View File
@@ -310,6 +310,7 @@ function AccountSettings() {
label: agent,
})) || []
}
wrapperClassName="w-[680px]"
defaultSelectedKey={settings.AGENT}
isClearable={false}
/>
@@ -502,6 +503,7 @@ function AccountSettings() {
label: language.label,
}))}
defaultSelectedKey={settings.LANGUAGE}
wrapperClassName="w-[680px]"
isClearable={false}
/>
+22 -55
View File
@@ -1,68 +1,35 @@
import React from "react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { setReplayJson } from "#/state/initial-query-slice";
import { useGitUser } from "#/hooks/query/use-git-user";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useConfig } from "#/hooks/query/use-config";
import { ReplaySuggestionBox } from "#/components/features/suggestions/replay-suggestion-box";
import { GitRepositoriesSuggestionBox } from "#/components/features/git/git-repositories-suggestion-box";
import { CodeNotInGitLink } from "#/components/features/git/code-not-in-github-link";
import { HeroHeading } from "#/components/shared/hero-heading";
import { TaskForm } from "#/components/shared/task-form";
import { convertFileToText } from "#/utils/convert-file-to-text";
import { ENABLE_TRAJECTORY_REPLAY } from "#/utils/feature-flags";
import { PrefetchPageLinks } from "react-router";
import { HomeHeader } from "#/components/features/home/home-header";
import { RepoConnector } from "#/components/features/home/repo-connector";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
import { useAuth } from "#/context/auth-context";
function Home() {
const dispatch = useDispatch();
const formRef = React.useRef<HTMLFormElement>(null);
<PrefetchPageLinks page="/conversations/:conversationId" />;
const { data: config } = useConfig();
const { data: user } = useGitUser();
const gitHubAuthUrl = useGitHubAuthUrl({
appMode: config?.APP_MODE || null,
gitHubClientId: config?.GITHUB_CLIENT_ID || null,
});
function HomeScreen() {
const { providersAreSet } = useAuth();
const [selectedRepoTitle, setSelectedRepoTitle] = React.useState<
string | null
>(null);
return (
<div
data-testid="home-screen"
className="bg-base-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2"
className="bg-base-secondary h-full flex flex-col rounded-xl px-[42px] pt-[42px] gap-8 overflow-y-auto"
>
<HeroHeading />
<div className="flex flex-col gap-1 w-full mt-8 md:w-[600px] items-center">
<div className="flex flex-col gap-2 w-full">
<TaskForm ref={formRef} />
</div>
<HomeHeader />
<div className="flex gap-4 w-full flex-col md:flex-row mt-8">
<GitRepositoriesSuggestionBox
handleSubmit={() => formRef.current?.requestSubmit()}
gitHubAuthUrl={gitHubAuthUrl}
user={user || null}
/>
{ENABLE_TRAJECTORY_REPLAY() && (
<ReplaySuggestionBox
onChange={async (event) => {
if (event.target.files) {
const json = event.target.files[0];
dispatch(setReplayJson(await convertFileToText(json)));
posthog.capture("json_file_uploaded");
formRef.current?.requestSubmit();
} else {
// TODO: handle error
}
}}
/>
)}
</div>
<div className="w-full flex justify-start mt-2 ml-2">
<CodeNotInGitLink />
</div>
</div>
<hr className="border-[#717888]" />
<main className="flex justify-between gap-4">
<RepoConnector
onRepoSelection={(title) => setSelectedRepoTitle(title)}
/>
{providersAreSet && <TaskSuggestions filterFor={selectedRepoTitle} />}
</main>
</div>
);
}
export default Home;
export default HomeScreen;
+8
View File
@@ -5,3 +5,11 @@
.button-base {
@apply bg-tertiary border border-neutral-600 rounded;
}
.heading {
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
}
.skeleton {
@apply bg-gray-400 rounded-md animate-pulse;
}
@@ -0,0 +1,28 @@
import {
SuggestedTask,
SuggestedTaskGroup,
} from "#/components/features/home/tasks/task.types";
/**
* Groups suggested tasks by their repository.
* @param tasks Array of suggested tasks
* @returns Array of suggested task groups
*/
export function groupSuggestedTasks(
tasks: SuggestedTask[],
): SuggestedTaskGroup[] {
const groupsMap: Record<string, SuggestedTaskGroup> = {};
for (const task of tasks) {
if (!groupsMap[task.repo]) {
groupsMap[task.repo] = {
title: task.repo,
tasks: [],
};
}
groupsMap[task.repo].tasks.push(task);
}
return Object.values(groupsMap);
}
+1 -1
View File
@@ -38,7 +38,7 @@ i18n.use(initReactI18next).init({
},
});
const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
export const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
configureStore({
reducer: rootReducer,
preloadedState,
+3 -3
View File
@@ -98,9 +98,9 @@ class SandboxConfig(BaseModel):
raise ValueError(f'Invalid sandbox configuration: {e}')
return sandbox_mapping
@model_validator(mode="after")
def set_default_base_image(self) -> "SandboxConfig":
@model_validator(mode='after')
def set_default_base_image(self) -> 'SandboxConfig':
if self.base_container_image is None:
self.base_container_image = 'nikolaik/python-nodejs:python3.12-nodejs22'
return self
+10 -12
View File
@@ -115,18 +115,16 @@ def initialize_repository_for_runtime(
)
provider_tokens = secret_store.provider_tokens if secret_store else None
repo_directory = None
if selected_repository and provider_tokens:
logger.debug(f'Selected repository {selected_repository}.')
repo_directory = call_async_from_sync(
runtime.clone_repo,
GENERAL_TIMEOUT,
provider_tokens,
selected_repository,
None,
)
# Run setup script if it exists
runtime.maybe_run_setup_script()
logger.debug(f'Selected repository {selected_repository}.')
repo_directory = call_async_from_sync(
runtime.clone_or_init_repo,
GENERAL_TIMEOUT,
provider_tokens,
selected_repository,
None,
)
# Run setup script if it exists
runtime.maybe_run_setup_script()
return repo_directory
+11 -5
View File
@@ -232,11 +232,17 @@ class EventStream(EventStore):
# pass each event to each callback in order
for key in sorted(self._subscribers.keys()):
callbacks = self._subscribers[key]
for callback_id in callbacks:
callback = callbacks[callback_id]
pool = self._thread_pools[key][callback_id]
future = pool.submit(callback, event)
future.add_done_callback(self._make_error_handler(callback_id, key))
# Create a copy of the keys to avoid "dictionary changed size during iteration" error
callback_ids = list(callbacks.keys())
for callback_id in callback_ids:
# Check if callback_id still exists (might have been removed during iteration)
if callback_id in callbacks:
callback = callbacks[callback_id]
pool = self._thread_pools[key][callback_id]
future = pool.submit(callback, event)
future.add_done_callback(
self._make_error_handler(callback_id, key)
)
def _make_error_handler(
self, callback_id: str, subscriber_id: str
+42 -29
View File
@@ -5,12 +5,12 @@ from typing import Any
import httpx
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import (
AuthenticationError,
BaseGitService,
GitService,
ProviderType,
Repository,
RequestMethod,
SuggestedTask,
TaskType,
UnknownException,
@@ -20,7 +20,7 @@ from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class GitHubService(GitService):
class GitHubService(BaseGitService, GitService):
BASE_URL = 'https://api.github.com'
token: SecretStr = SecretStr('')
refresh = False
@@ -32,6 +32,7 @@ class GitHubService(GitService):
external_auth_token: SecretStr | None = None,
token: SecretStr | None = None,
external_token_manager: bool = False,
base_domain: str | None = None,
):
self.user_id = user_id
self.external_token_manager = external_token_manager
@@ -39,9 +40,16 @@ class GitHubService(GitService):
if token:
self.token = token
if base_domain:
self.BASE_URL = f'https://{base_domain}/api/v3'
@property
def provider(self) -> str:
return ProviderType.GITHUB.value
async def _get_github_headers(self) -> dict:
"""Retrieve the GH Token from settings store to construct the headers."""
if self.user_id and not self.token:
if not self.token:
self.token = await self.get_latest_token()
return {
@@ -55,18 +63,35 @@ class GitHubService(GitService):
async def get_latest_token(self) -> SecretStr | None:
return self.token
async def _fetch_data(
self, url: str, params: dict | None = None
async def _make_request(
self,
url: str,
params: dict | None = None,
method: RequestMethod = RequestMethod.GET,
) -> tuple[Any, dict]:
try:
async with httpx.AsyncClient() as client:
github_headers = await self._get_github_headers()
response = await client.get(url, headers=github_headers, params=params)
# Make initial request
response = await self.execute_request(
client=client,
url=url,
headers=github_headers,
params=params,
method=method,
)
# Handle token refresh if needed
if self.refresh and self._has_token_expired(response.status_code):
await self.get_latest_token()
github_headers = await self._get_github_headers()
response = await client.get(
url, headers=github_headers, params=params
response = await self.execute_request(
client=client,
url=url,
headers=github_headers,
params=params,
method=method,
)
response.raise_for_status()
@@ -77,19 +102,13 @@ class GitHubService(GitService):
return response.json(), headers
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
raise AuthenticationError('Invalid Github token')
logger.warning(f'Status error on GH API: {e}')
raise UnknownException('Unknown error')
raise self.handle_http_status_error(e)
except httpx.HTTPError as e:
logger.warning(f'HTTP error on GH API: {e}')
raise UnknownException('Unknown error')
raise self.handle_http_error(e)
async def get_user(self) -> User:
url = f'{self.BASE_URL}/user'
response, _ = await self._fetch_data(url)
response, _ = await self._make_request(url)
return User(
id=response.get('id'),
@@ -120,7 +139,7 @@ class GitHubService(GitService):
while len(repos) < max_repos:
page_params = {**params, 'page': str(page)}
response, headers = await self._fetch_data(url, page_params)
response, headers = await self._make_request(url, page_params)
# Extract repositories from response
page_repos = response.get(extract_key, []) if extract_key else response
@@ -186,7 +205,7 @@ class GitHubService(GitService):
async def get_installation_ids(self) -> list[int]:
url = f'{self.BASE_URL}/user/installations'
response, _ = await self._fetch_data(url)
response, _ = await self._make_request(url)
installations = response.get('installations', [])
return [i['id'] for i in installations]
@@ -203,7 +222,7 @@ class GitHubService(GitService):
'order': order,
}
response, _ = await self._fetch_data(url, params)
response, _ = await self._make_request(url, params)
repo_items = response.get('items', [])
repos = [
@@ -241,15 +260,9 @@ class GitHubService(GitService):
return dict(result)
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
raise AuthenticationError('Invalid Github token')
logger.warning(f'Status error on GH API: {e}')
raise UnknownException('Unknown error')
raise self.handle_http_status_error(e)
except httpx.HTTPError as e:
logger.warning(f'HTTP error on GH API: {e}')
raise UnknownException('Unknown error')
raise self.handle_http_error(e)
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories.
+43 -28
View File
@@ -4,12 +4,12 @@ from typing import Any
import httpx
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import (
AuthenticationError,
BaseGitService,
GitService,
ProviderType,
Repository,
RequestMethod,
UnknownException,
User,
)
@@ -17,11 +17,12 @@ from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class GitLabService(GitService):
class GitLabService(BaseGitService, GitService):
BASE_URL = 'https://gitlab.com/api/v4'
GRAPHQL_URL = 'https://gitlab.com/api/graphql'
token: SecretStr = SecretStr('')
refresh = False
def __init__(
self,
@@ -30,6 +31,7 @@ class GitLabService(GitService):
external_auth_token: SecretStr | None = None,
token: SecretStr | None = None,
external_token_manager: bool = False,
base_domain: str | None = None,
):
self.user_id = user_id
self.external_token_manager = external_token_manager
@@ -37,11 +39,19 @@ class GitLabService(GitService):
if token:
self.token = token
if base_domain:
self.BASE_URL = f'https://{base_domain}/api/v4'
self.GRAPHQL_URL = f'https://{base_domain}/api/graphql'
@property
def provider(self) -> str:
return ProviderType.GITLAB.value
async def _get_gitlab_headers(self) -> dict[str, Any]:
"""
Retrieve the GitLab Token to construct the headers
"""
if self.user_id and not self.token:
if not self.token:
self.token = await self.get_latest_token()
return {
@@ -54,18 +64,35 @@ class GitLabService(GitService):
async def get_latest_token(self) -> SecretStr | None:
return self.token
async def _fetch_data(
self, url: str, params: dict | None = None
async def _make_request(
self,
url: str,
params: dict | None = None,
method: RequestMethod = RequestMethod.GET,
) -> tuple[Any, dict]:
try:
async with httpx.AsyncClient() as client:
gitlab_headers = await self._get_gitlab_headers()
response = await client.get(url, headers=gitlab_headers, params=params)
# Make initial request
response = await self.execute_request(
client=client,
url=url,
headers=gitlab_headers,
params=params,
method=method,
)
# Handle token refresh if needed
if self.refresh and self._has_token_expired(response.status_code):
await self.get_latest_token()
gitlab_headers = await self._get_gitlab_headers()
response = await client.get(
url, headers=gitlab_headers, params=params
response = await self.execute_request(
client=client,
url=url,
headers=gitlab_headers,
params=params,
method=method,
)
response.raise_for_status()
@@ -76,15 +103,9 @@ class GitLabService(GitService):
return response.json(), headers
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
raise AuthenticationError('Invalid GitLab token')
logger.warning(f'Status error on GL API: {e}')
raise UnknownException('Unknown error')
raise self.handle_http_status_error(e)
except httpx.HTTPError as e:
logger.warning(f'HTTP error on GL API: {e}')
raise UnknownException('Unknown error')
raise self.handle_http_error(e)
async def execute_graphql_query(self, query: str, variables: dict[str, Any]) -> Any:
"""
@@ -132,19 +153,13 @@ class GitLabService(GitService):
return result.get('data')
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
raise AuthenticationError('Invalid GitLab token')
logger.warning(f'Status error on GL API: {e}')
raise UnknownException('Unknown error')
raise self.handle_http_status_error(e)
except httpx.HTTPError as e:
logger.warning(f'HTTP error on GL API: {e}')
raise UnknownException('Unknown error')
raise self.handle_http_error(e)
async def get_user(self) -> User:
url = f'{self.BASE_URL}/user'
response, _ = await self._fetch_data(url)
response, _ = await self._make_request(url)
return User(
id=response.get('id'),
@@ -168,7 +183,7 @@ class GitLabService(GitService):
'visibility': 'public',
}
response, _ = await self._fetch_data(url, params)
response, _ = await self._make_request(url, params)
repos = [
Repository(
id=repo.get('id'),
@@ -204,7 +219,7 @@ class GitLabService(GitService):
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
'membership': 1, # Use 1 instead of True
}
response, headers = await self._fetch_data(url, params)
response, headers = await self._make_request(url, params)
if not response: # No more repositories
break
+58 -1
View File
@@ -1,8 +1,11 @@
from abc import ABC, abstractmethod
from enum import Enum
from typing import Protocol
from typing import Any, Protocol
from httpx import AsyncClient, HTTPError, HTTPStatusError
from pydantic import BaseModel, SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.server.types import AppMode
@@ -57,6 +60,60 @@ class UnknownException(ValueError):
pass
class RateLimitError(ValueError):
"""Raised when the git provider's API rate limits are exceeded."""
pass
class RequestMethod(Enum):
POST = 'post'
GET = 'get'
class BaseGitService(ABC):
@property
def provider(self) -> str:
raise NotImplementedError('Subclasses must implement the provider property')
# Method used to satisfy mypy for abstract class definition
@abstractmethod
async def _make_request(
self,
url: str,
params: dict | None = None,
method: RequestMethod = RequestMethod.GET,
) -> tuple[Any, dict]: ...
async def execute_request(
self,
client: AsyncClient,
url: str,
headers: dict,
params: dict | None,
method: RequestMethod = RequestMethod.GET,
):
if method == RequestMethod.POST:
return await client.post(url, headers=headers, json=params)
return await client.get(url, headers=headers, params=params)
def handle_http_status_error(
self, e: HTTPStatusError
) -> AuthenticationError | RateLimitError | UnknownException:
if e.response.status_code == 401:
return AuthenticationError(f'Invalid {self.provider} token')
elif e.response.status_code == 429:
logger.warning(f'Rate limit exceeded on {self.provider} API: {e}')
return RateLimitError('GitHub API rate limit exceeded')
logger.warning(f'Status error on {self.provider} API: {e}')
return UnknownException('Unknown error')
def handle_http_error(self, e: HTTPError) -> UnknownException:
logger.warning(f'HTTP error on {self.provider} API: {e}')
return UnknownException('Unknown error')
class GitService(Protocol):
"""Protocol defining the interface for Git service providers"""
+5 -3
View File
@@ -5,7 +5,9 @@ from openhands.integrations.gitlab.gitlab_service import GitLabService
from openhands.integrations.provider import ProviderType
async def validate_provider_token(token: SecretStr) -> ProviderType | None:
async def validate_provider_token(
token: SecretStr, base_domain: str | None = None
) -> ProviderType | None:
"""
Determine whether a token is for GitHub or GitLab by attempting to get user info
from both services.
@@ -20,7 +22,7 @@ async def validate_provider_token(token: SecretStr) -> ProviderType | None:
"""
# Try GitHub first
try:
github_service = GitHubService(token=token)
github_service = GitHubService(token=token, base_domain=base_domain)
await github_service.get_user()
return ProviderType.GITHUB
except Exception:
@@ -28,7 +30,7 @@ async def validate_provider_token(token: SecretStr) -> ProviderType | None:
# Try GitLab next
try:
gitlab_service = GitLabService(token=token)
gitlab_service = GitLabService(token=token, base_domain=base_domain)
await gitlab_service.get_user()
return ProviderType.GITLAB
except Exception:
+7 -1
View File
@@ -42,6 +42,7 @@ from openhands.resolver.utils import (
reset_logger_for_multiprocessing,
)
from openhands.runtime.base import Runtime
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
# Don't make this confgurable for now, unless we have other competitive agents
AGENT_CLASS = 'CodeActAgent'
@@ -688,7 +689,12 @@ def main() -> None:
if not token:
raise ValueError('Token is required.')
platform = identify_token(token, my_args.selected_repo, my_args.base_domain)
platform = call_async_from_sync(
identify_token,
GENERAL_TIMEOUT,
token,
my_args.base_domain,
)
api_key = my_args.llm_api_key or os.environ['LLM_API_KEY']
model = my_args.llm_model or os.environ['LLM_MODEL']
+7 -1
View File
@@ -22,6 +22,7 @@ from openhands.resolver.io_utils import (
from openhands.resolver.patching import apply_diff, parse_patch
from openhands.resolver.resolver_output import ResolverOutput
from openhands.resolver.utils import identify_token
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
def apply_patch(repo_dir: str, patch: str) -> None:
@@ -685,7 +686,12 @@ def main() -> None:
)
username = my_args.username if my_args.username else os.getenv('GIT_USERNAME')
platform = identify_token(token, my_args.selected_repo, my_args.base_domain)
platform = call_async_from_sync(
identify_token,
GENERAL_TIMEOUT,
token,
my_args.base_domain,
)
api_key = my_args.llm_api_key or os.environ['LLM_API_KEY']
llm_config = LLMConfig(
+8 -56
View File
@@ -4,7 +4,7 @@ import os
import re
from typing import Callable
import httpx
from pydantic import SecretStr
from openhands.controller.state.state import State
from openhands.core.logger import get_console_handler
@@ -12,69 +12,21 @@ from openhands.core.logger import openhands_logger as logger
from openhands.events.action import Action
from openhands.events.action.message import MessageAction
from openhands.integrations.service_types import ProviderType
from openhands.integrations.utils import validate_provider_token
def identify_token(
token: str, selected_repo: str | None = None, base_domain: str | None = 'github.com'
) -> ProviderType:
async def identify_token(token: str, base_domain: str | None) -> ProviderType:
"""
Identifies whether a token belongs to GitHub or GitLab.
Parameters:
token (str): The personal access token to check.
selected_repo (str): Repository in format "owner/repo" for GitHub Actions token validation.
base_domain (str): The base domain for GitHub Enterprise (default: "github.com").
Returns:
ProviderType: "GitHub" if the token is valid for GitHub,
"GitLab" if the token is valid for GitLab,
"Invalid" if the token is not recognized by either.
base_domain (str): Custom base domain for provider (e.g GitHub Enterprise)
"""
# Determine GitHub API base URL based on domain
if base_domain is None or base_domain == 'github.com':
github_api_base = 'https://api.github.com'
else:
github_api_base = f'https://{base_domain}/api/v3'
provider = await validate_provider_token(SecretStr(token), base_domain)
if not provider:
raise ValueError('Token is invalid.')
# Try GitHub Actions token format (Bearer) with repo endpoint if repo is provided
if selected_repo:
github_repo_url = f'{github_api_base}/repos/{selected_repo}'
github_bearer_headers = {
'Authorization': f'Bearer {token}',
'Accept': 'application/vnd.github+json',
}
try:
github_repo_response = httpx.get(
github_repo_url, headers=github_bearer_headers, timeout=5
)
if github_repo_response.status_code == 200:
return ProviderType.GITHUB
except httpx.HTTPError as e:
logger.error(f'Error connecting to GitHub API (selected_repo check): {e}')
# Try GitHub PAT format (token)
github_url = f'{github_api_base}/user'
github_headers = {'Authorization': f'token {token}'}
try:
github_response = httpx.get(github_url, headers=github_headers, timeout=5)
if github_response.status_code == 200:
return ProviderType.GITHUB
except httpx.HTTPError as e:
logger.error(f'Error connecting to GitHub API: {e}')
gitlab_url = 'https://gitlab.com/api/v4/user'
gitlab_headers = {'Authorization': f'Bearer {token}'}
try:
gitlab_response = httpx.get(gitlab_url, headers=gitlab_headers, timeout=5)
if gitlab_response.status_code == 200:
return ProviderType.GITLAB
except httpx.HTTPError as e:
logger.error(f'Error connecting to GitLab API: {e}')
raise ValueError('Token is invalid.')
return provider
def codeact_user_response(
+11 -54
View File
@@ -21,7 +21,7 @@ from zipfile import ZipFile
from binaryornot.check import is_binary
from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import APIKeyHeader
from openhands_aci.editor.editor import OHEditor
from openhands_aci.editor.exceptions import ToolError
@@ -57,10 +57,11 @@ from openhands.events.observation import (
from openhands.events.serialization import event_from_dict, event_to_dict
from openhands.runtime.browser import browse
from openhands.runtime.browser.browser_env import BrowserEnv
from openhands.runtime.file_viewer_server import start_file_viewer_server
from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCodePlugin
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.async_bash import AsyncBashSession
from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.file_viewer import generate_file_viewer_html
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.memory_monitor import MemoryMonitor
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
@@ -532,6 +533,7 @@ class ActionExecutor:
if __name__ == '__main__':
logger.warning('Starting Action Execution Server')
parser = argparse.ArgumentParser()
parser.add_argument('port', type=int, help='Port to listen on')
parser.add_argument('--working-dir', type=str, help='Working directory')
@@ -550,10 +552,13 @@ if __name__ == '__main__':
# example: python client.py 8000 --working-dir /workspace --plugins JupyterRequirement
args = parser.parse_args()
port_path = '/tmp/oh-server-url'
os.makedirs(os.path.dirname(port_path), exist_ok=True)
with open(port_path, 'w') as f:
f.write(f'http://127.0.0.1:{args.port}')
# Start the file viewer server in a separate thread
logger.info('Starting file viewer server')
_file_viewer_port = find_available_tcp_port(
min_port=args.port + 1, max_port=args.port + 10000
)
server_url, _ = start_file_viewer_server(port=_file_viewer_port)
logger.info(f'File viewer server started at {server_url}')
plugins_to_load: list[Plugin] = []
if args.plugins:
@@ -839,53 +844,5 @@ if __name__ == '__main__':
logger.error(f'Error listing files: {e}')
return []
@app.get('/view')
async def view_file(path: str, request: Request):
"""View a file using an embedded viewer.
Args:
path (str): The absolute path of the file to view.
request (Request): The FastAPI request object.
Returns:
HTMLResponse: An HTML page with an appropriate viewer for the file.
"""
# Security check: Only allow requests from localhost
client_host = request.client.host if request.client else None
if client_host not in ['127.0.0.1', 'localhost', '::1']:
logger.warning(f'Unauthorized file view attempt from {client_host}')
return HTMLResponse(
content='<h1>Access Denied</h1><p>This endpoint is only accessible from localhost</p>',
status_code=403,
)
if not os.path.isabs(path):
return HTMLResponse(
content=f'<h1>Error: Path must be absolute</h1><p>{path}</p>',
status_code=400,
)
if not os.path.exists(path):
return HTMLResponse(
content=f'<h1>Error: File not found</h1><p>{path}</p>', status_code=404
)
if os.path.isdir(path):
return HTMLResponse(
content=f'<h1>Error: Path is a directory</h1><p>{path}</p>',
status_code=400,
)
try:
html_content = generate_file_viewer_html(path)
return HTMLResponse(content=html_content)
except Exception as e:
logger.error(f'Error serving file viewer: {str(e)}')
return HTMLResponse(
content=f'<h1>Error viewing file</h1><p>{path}</p><p>{str(e)}</p>',
status_code=500,
)
logger.debug(f'Starting action execution API on port {args.port}')
run(app, host='0.0.0.0', port=args.port)
+23 -4
View File
@@ -309,13 +309,30 @@ class Runtime(FileEditRuntimeMixin):
return
self.event_stream.add_event(observation, source) # type: ignore[arg-type]
async def clone_repo(
async def clone_or_init_repo(
self,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
selected_repository: str | Repository,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None,
selected_repository: str | Repository | None,
selected_branch: str | None,
repository_provider: ProviderType = ProviderType.GITHUB,
) -> str:
if not selected_repository:
# In SaaS mode (indicated by user_id being set), always run git init
# In OSS mode, only run git init if workspace_base is not set
if self.user_id or not self.config.workspace_base:
logger.debug(
'No repository selected. Initializing a new git repository in the workspace.'
)
action = CmdRunAction(
command='git init',
)
self.run_action(action)
else:
logger.info(
'In workspace mount mode, not initializing a new git repository.'
)
return ''
provider_domains = {
ProviderType.GITHUB: 'github.com',
ProviderType.GITLAB: 'gitlab.com',
@@ -327,9 +344,11 @@ class Runtime(FileEditRuntimeMixin):
else selected_repository.git_provider
)
if not git_provider_tokens:
raise RuntimeError('Need git provider tokens to clone repo')
git_token = git_provider_tokens[chosen_provider].token
if not git_token:
raise RuntimeError('Require valid git token to clone repo')
raise RuntimeError('Need a valid git token to clone repo')
domain = provider_domains[chosen_provider]
repository = (
+115
View File
@@ -0,0 +1,115 @@
"""
A tiny, isolated server that provides only the /view endpoint from the action execution server.
This server has no authentication and only listens to localhost traffic.
"""
import os
import threading
from typing import Tuple
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from uvicorn import Config, Server
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.utils.file_viewer import generate_file_viewer_html
def create_app() -> FastAPI:
"""Create the FastAPI application."""
app = FastAPI(
title='File Viewer Server', openapi_url=None, docs_url=None, redoc_url=None
)
@app.get('/')
async def root():
"""Root endpoint to check if the server is running."""
return {'status': 'File viewer server is running'}
@app.get('/view')
async def view_file(path: str, request: Request):
"""View a file using an embedded viewer.
Args:
path (str): The absolute path of the file to view.
request (Request): The FastAPI request object.
Returns:
HTMLResponse: An HTML page with an appropriate viewer for the file.
"""
# Security check: Only allow requests from localhost
client_host = request.client.host if request.client else None
if client_host not in ['127.0.0.1', 'localhost', '::1']:
return HTMLResponse(
content='<h1>Access Denied</h1><p>This endpoint is only accessible from localhost</p>',
status_code=403,
)
if not os.path.isabs(path):
return HTMLResponse(
content=f'<h1>Error: Path must be absolute</h1><p>{path}</p>',
status_code=400,
)
if not os.path.exists(path):
return HTMLResponse(
content=f'<h1>Error: File not found</h1><p>{path}</p>', status_code=404
)
if os.path.isdir(path):
return HTMLResponse(
content=f'<h1>Error: Path is a directory</h1><p>{path}</p>',
status_code=400,
)
try:
html_content = generate_file_viewer_html(path)
return HTMLResponse(content=html_content)
except Exception as e:
return HTMLResponse(
content=f'<h1>Error viewing file</h1><p>{path}</p><p>{str(e)}</p>',
status_code=500,
)
return app
def start_file_viewer_server(port: int) -> Tuple[str, threading.Thread]:
"""Start the file viewer server on the specified port or find an available one.
Args:
port (int, optional): The port to bind to. If None, an available port will be found.
Returns:
Tuple[str, threading.Thread]: The server URL and the thread object.
"""
# Save the server URL to a file
server_url = f'http://localhost:{port}'
port_path = '/tmp/oh-server-url'
os.makedirs(os.path.dirname(port_path), exist_ok=True)
with open(port_path, 'w') as f:
f.write(server_url)
logger.info(f'File viewer server URL saved to /tmp/oh-server-url: {server_url}')
logger.info(f'Starting file viewer server on port {port}')
app = create_app()
config = Config(app=app, host='127.0.0.1', port=port, log_level='error')
server = Server(config=config)
# Run the server in a new thread
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
return server_url, thread
if __name__ == '__main__':
url, thread = start_file_viewer_server(port=8000)
# Keep the main thread running
try:
thread.join()
except KeyboardInterrupt:
logger.info('Server stopped')
+13 -26
View File
@@ -5,6 +5,7 @@ This runtime runs the action_execution_server directly on the local machine with
import os
import shutil
import subprocess
import sys
import tempfile
import threading
from typing import Callable
@@ -204,35 +205,21 @@ class LocalRuntime(ActionExecutionClient):
env = os.environ.copy()
# Get the code repo path
code_repo_path = os.path.dirname(os.path.dirname(openhands.__file__))
env['PYTHONPATH'] = f'{code_repo_path}:$PYTHONPATH'
env['PYTHONPATH'] = f'{code_repo_path}{os.pathsep}{env.get("PYTHONPATH", "")}'
env['OPENHANDS_REPO_PATH'] = code_repo_path
env['LOCAL_RUNTIME_MODE'] = '1'
# Extract the poetry venv by parsing output of a shell command
# Equivalent to:
# run poetry show -v | head -n 1 | awk '{print $2}'
poetry_show_first_line = subprocess.check_output( # noqa: ASYNC101
['poetry', 'show', '-v'],
env=env,
cwd=code_repo_path,
text=True,
# Redirect stderr to stdout
# Needed since there might be a message on stderr like
# "Skipping virtualenv creation, as specified in config file."
# which will cause the command to fail
stderr=subprocess.STDOUT,
shell=False,
).splitlines()[0]
if not poetry_show_first_line.lower().startswith('found:'):
raise RuntimeError(
'Cannot find poetry venv path. Please check your poetry installation.'
f'First line of poetry show -v: {poetry_show_first_line}'
)
# Split off the 'Found:' part
poetry_venvs_path = poetry_show_first_line.split(':')[1].strip()
env['POETRY_VIRTUALENVS_PATH'] = poetry_venvs_path
logger.debug(f'POETRY_VIRTUALENVS_PATH: {poetry_venvs_path}')
check_dependencies(code_repo_path, poetry_venvs_path)
# Derive environment paths using sys.executable
interpreter_path = sys.executable
python_bin_path = os.path.dirname(interpreter_path)
env_root_path = os.path.dirname(python_bin_path)
# Prepend the interpreter's bin directory to PATH for subprocesses
env['PATH'] = f'{python_bin_path}{os.pathsep}{env.get("PATH", "")}'
logger.debug(f'Updated PATH for subprocesses: {env["PATH"]}')
# Check dependencies using the derived env_root_path
check_dependencies(code_repo_path, env_root_path)
self.server_process = subprocess.Popen( # noqa: ASYNC101
cmd,
stdout=subprocess.PIPE,
@@ -48,13 +48,7 @@ class JupyterPlugin(Plugin):
'OPENHANDS_REPO_PATH environment variable is not set. '
'This is required for the jupyter plugin to work with LocalRuntime.'
)
# assert POETRY_VIRTUALENVS_PATH is set
poetry_venvs_path = os.environ.get('POETRY_VIRTUALENVS_PATH')
if not poetry_venvs_path:
raise ValueError(
'POETRY_VIRTUALENVS_PATH environment variable is not set. '
'This is required for the jupyter plugin to work with LocalRuntime.'
)
# The correct environment is ensured by the PATH in LocalRuntime.
poetry_prefix = f'cd {code_repo_path}\n'
jupyter_launch_command = (
f"{prefix}/bin/bash << 'EOF'\n"
+4 -5
View File
@@ -323,11 +323,10 @@ class AgentSession:
)
return False
if selected_repository and git_provider_tokens:
await self.runtime.clone_repo(
git_provider_tokens, selected_repository, selected_branch
)
await call_sync_from_async(self.runtime.maybe_run_setup_script)
await self.runtime.clone_or_init_repo(
git_provider_tokens, selected_repository, selected_branch
)
await call_sync_from_async(self.runtime.maybe_run_setup_script)
self.logger.debug(
f'Runtime initialized with plugins: {[plugin.name for plugin in self.runtime.plugins]}'
Generated
+27 -26
View File
@@ -496,18 +496,18 @@ files = [
[[package]]
name = "boto3"
version = "1.37.37"
version = "1.37.38"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "boto3-1.37.37-py3-none-any.whl", hash = "sha256:d125cb11e22817f7a2581bade4bf7b75247b401888890239ceb5d3e902ccaf38"},
{file = "boto3-1.37.37.tar.gz", hash = "sha256:752d31105a45e3e01c8c68471db14ae439990b75a35e72b591ca528e2575b28f"},
{file = "boto3-1.37.38-py3-none-any.whl", hash = "sha256:b6d42803607148804dff82389757827a24ce9271f0583748853934c86310999f"},
{file = "boto3-1.37.38.tar.gz", hash = "sha256:88c02910933ab7777597d1ca7c62375f52822e0aa1a8e0c51b2598a547af42b2"},
]
[package.dependencies]
botocore = ">=1.37.37,<1.38.0"
botocore = ">=1.37.38,<1.38.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.11.0,<0.12.0"
@@ -516,14 +516,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "boto3-stubs"
version = "1.37.37"
description = "Type annotations for boto3 1.37.37 generated with mypy-boto3-builder 8.10.1"
version = "1.37.38"
description = "Type annotations for boto3 1.37.38 generated with mypy-boto3-builder 8.10.1"
optional = false
python-versions = ">=3.8"
groups = ["evaluation"]
files = [
{file = "boto3_stubs-1.37.37-py3-none-any.whl", hash = "sha256:937fabdc226b6661d90b7abb0dcaf4450c08e6e334d726381ba7479672d828c6"},
{file = "boto3_stubs-1.37.37.tar.gz", hash = "sha256:e467b7aa64c96f71266e3d3d763cd826e34e4063d511c0dec4341d3071d3428c"},
{file = "boto3_stubs-1.37.38-py3-none-any.whl", hash = "sha256:78418c10b43f1b45d877213a085acac7bcdb23e9c0ab294af04dffe9fc4310b5"},
{file = "boto3_stubs-1.37.38.tar.gz", hash = "sha256:d78c2de88e9f1a60bef05cfad5b8edc051f1762be0865c83bebe716448f56510"},
]
[package.dependencies]
@@ -579,7 +579,7 @@ bedrock-data-automation-runtime = ["mypy-boto3-bedrock-data-automation-runtime (
bedrock-runtime = ["mypy-boto3-bedrock-runtime (>=1.37.0,<1.38.0)"]
billing = ["mypy-boto3-billing (>=1.37.0,<1.38.0)"]
billingconductor = ["mypy-boto3-billingconductor (>=1.37.0,<1.38.0)"]
boto3 = ["boto3 (==1.37.37)"]
boto3 = ["boto3 (==1.37.38)"]
braket = ["mypy-boto3-braket (>=1.37.0,<1.38.0)"]
budgets = ["mypy-boto3-budgets (>=1.37.0,<1.38.0)"]
ce = ["mypy-boto3-ce (>=1.37.0,<1.38.0)"]
@@ -943,14 +943,14 @@ xray = ["mypy-boto3-xray (>=1.37.0,<1.38.0)"]
[[package]]
name = "botocore"
version = "1.37.37"
version = "1.37.38"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "botocore-1.37.37-py3-none-any.whl", hash = "sha256:eb730ff978f47c02f0c8ed07bccdc0db6d8fa098ed32ac31bee1da0e9be480d1"},
{file = "botocore-1.37.37.tar.gz", hash = "sha256:3eadde6fed95c4cb469cc39d1c3558528b7fa76d23e7e16d4bddc77250431a64"},
{file = "botocore-1.37.38-py3-none-any.whl", hash = "sha256:23b4097780e156a4dcaadfc1ed156ce25cb95b6087d010c4bb7f7f5d9bc9d219"},
{file = "botocore-1.37.38.tar.gz", hash = "sha256:c3ea386177171f2259b284db6afc971c959ec103fa2115911c4368bea7cbbc5d"},
]
[package.dependencies]
@@ -3793,14 +3793,14 @@ files = [
[[package]]
name = "json-repair"
version = "0.41.1"
version = "0.42.0"
description = "A package to repair broken json strings"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "json_repair-0.41.1-py3-none-any.whl", hash = "sha256:0e181fd43a696887881fe19fed23422a54b3e4c558b6ff27a86a8c3ddde9ae79"},
{file = "json_repair-0.41.1.tar.gz", hash = "sha256:bba404b0888c84a6b86ecc02ec43b71b673cfee463baf6da94e079c55b136565"},
{file = "json_repair-0.42.0-py3-none-any.whl", hash = "sha256:7b6805162053dfe65722e961bc51b5eecec0582ec8a8e0fd218d33e8de757daf"},
{file = "json_repair-0.42.0.tar.gz", hash = "sha256:1a901f706c5b6b4325f0f79b53b0d998c5b327070e98b530da71cc5a3eda8616"},
]
[[package]]
@@ -4882,14 +4882,14 @@ files = [
[[package]]
name = "modal"
version = "0.74.14"
version = "0.74.15"
description = "Python client library for Modal"
optional = false
python-versions = ">=3.9"
groups = ["main", "evaluation"]
files = [
{file = "modal-0.74.14-py3-none-any.whl", hash = "sha256:eb3edf5aa7a105a11c32c0f18aba240766d4a8b8089b33b6458f7d5eab632feb"},
{file = "modal-0.74.14.tar.gz", hash = "sha256:7757518feb53cca3e62022ce8ed9ba389c831a0c3cbe967de00e6dd2217aac0a"},
{file = "modal-0.74.15-py3-none-any.whl", hash = "sha256:084e898ab202ccd698fd277d9dc9e9cec8d4b0954a1c09d4ba529f0446ab3526"},
{file = "modal-0.74.15.tar.gz", hash = "sha256:95512811ebd42a52fa03724f60d0d1c32259788351e798d0d695974d94b2e49c"},
]
[package.dependencies]
@@ -7686,6 +7686,7 @@ python-versions = "<4,>=3.7"
groups = ["test"]
files = [
{file = "reportlab-4.4.0-py3-none-any.whl", hash = "sha256:0a993f1d4a765fcbdf4e26adc96b3351004ebf4d27583340595ba7edafebec32"},
{file = "reportlab-4.4.0.tar.gz", hash = "sha256:a64d85513910e246c21dc97ccc3c9054a1d44370bf8fc1fab80af937814354d5"},
]
[package.dependencies]
@@ -7976,14 +7977,14 @@ files = [
[[package]]
name = "runloop-api-client"
version = "0.30.0"
version = "0.31.0"
description = "The official Python library for the runloop API"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "runloop_api_client-0.30.0-py3-none-any.whl", hash = "sha256:5e922399bdd0f67c6e19c212d06203640580a50c3fc8760eef0eea6dd5259ee4"},
{file = "runloop_api_client-0.30.0.tar.gz", hash = "sha256:9211d9961d9aa2372d7f5ecd36c8197a72bfd1227d145caa9c710b8302e50e66"},
{file = "runloop_api_client-0.31.0-py3-none-any.whl", hash = "sha256:1eb716a20b268e081bdbcf5b5d1df9ab6eb258a0b929130210a3b643048159c7"},
{file = "runloop_api_client-0.31.0.tar.gz", hash = "sha256:78992595fd34f98470aa73b8f5b92983414e4878218239e531a9371c5570a13d"},
]
[package.dependencies]
@@ -8534,19 +8535,19 @@ test = ["pylint", "pytest", "pytest-black", "pytest-cov", "pytest-pylint"]
[[package]]
name = "stripe"
version = "12.0.0"
version = "12.0.1"
description = "Python bindings for the Stripe API"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "stripe-12.0.0-py2.py3-none-any.whl", hash = "sha256:1105bbceca8c0aead941c7c3ec676727610cb6437b6e8905379f2ccc370eeafd"},
{file = "stripe-12.0.0.tar.gz", hash = "sha256:1bc64192b6bd853fa75e2246ad553baa28e27e348eb2c3336b4d8b021d27aae5"},
{file = "stripe-12.0.1-py2.py3-none-any.whl", hash = "sha256:b10b19dbd0622868b98a7c6e879ebde704be96ad75c780944bca4069bb427988"},
{file = "stripe-12.0.1.tar.gz", hash = "sha256:3fc7cc190946d8ebcc5b637e7e04f387d61b9c5156a89619a3ba90704ac09d4a"},
]
[package.dependencies]
requests = {version = ">=2.20", markers = "python_version >= \"3.0\""}
typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""}
typing_extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""}
[[package]]
name = "swebench"
@@ -10255,4 +10256,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12"
content-hash = "82763fb3ce12aba7fbf76651fa3ea72be700feaabb4d944540fc1156745bb6c1"
content-hash = "315f92801db294eaab22823ba948a2658ea8040bbae97ddcc434e9ac127af53b"
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.33.0"
version = "0.34.0"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"
@@ -58,7 +58,7 @@ protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
opentelemetry-api = "1.25.0"
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
modal = ">=0.66.26,<0.75.0"
runloop-api-client = "0.30.0"
runloop-api-client = "0.31.0"
libtmux = ">=0.37,<0.40"
pygithub = "^2.5.0"
joblib = "*"
@@ -1269,7 +1269,7 @@ def test_main(
# Run main function
main()
mock_identify_token.assert_called_with('mock_token', None, ANY)
mock_identify_token.assert_called_with('mock_token', mock_args.base_domain)
llm_config = LLMConfig(
model=mock_args.llm_model,
@@ -1171,7 +1171,7 @@ def test_main(
# Run main function
main()
mock_identify_token.assert_called_with('mock_token', None, ANY)
mock_identify_token.assert_called_with('mock_token', mock_args.base_domain)
llm_config = LLMConfig(
model=mock_args.llm_model,
+67 -1
View File
@@ -8,7 +8,7 @@ import pytest
from pytest import TempPathFactory
from openhands.core.schema import ActionType, ObservationType
from openhands.events import EventSource, EventStream
from openhands.events import EventSource, EventStream, EventStreamSubscriber
from openhands.events.action import (
NullAction,
)
@@ -444,6 +444,72 @@ def test_cache_page_performance(temp_dir: str):
# In real-world scenarios with many more events, the performance difference would be more significant.
def test_callback_dictionary_modification(temp_dir: str):
"""Test that the event stream can handle dictionary modification during iteration.
This test verifies that the fix for the 'dictionary changed size during iteration' error works.
The test adds a callback that adds a new callback during iteration, which would cause an error
without the fix.
"""
file_store = get_file_store('local', temp_dir)
event_stream = EventStream('callback_test', file_store)
# Track callback execution
callback_executed = [False, False, False]
# Define a callback that will be added during iteration
def callback_added_during_iteration(event):
callback_executed[2] = True
# First callback that will be called
def callback1(event):
callback_executed[0] = True
# This callback will add a new callback during iteration
# Without our fix, this would cause a "dictionary changed size during iteration" error
event_stream.subscribe(
EventStreamSubscriber.TEST, callback_added_during_iteration, 'callback3'
)
# Second callback that will be called
def callback2(event):
callback_executed[1] = True
# Subscribe both callbacks
event_stream.subscribe(EventStreamSubscriber.TEST, callback1, 'callback1')
event_stream.subscribe(EventStreamSubscriber.TEST, callback2, 'callback2')
# Add an event to trigger callbacks
event_stream.add_event(NullObservation('test'), EventSource.AGENT)
# Give some time for the callbacks to execute
time.sleep(0.5)
# Verify that the first two callbacks were executed
assert callback_executed[0] is True, 'First callback should have been executed'
assert callback_executed[1] is True, 'Second callback should have been executed'
# The third callback should not have been executed for this event
# since it was added during iteration
assert (
callback_executed[2] is False
), 'Third callback should not have been executed for this event'
# Add another event to trigger all callbacks including the newly added one
callback_executed = [False, False, False] # Reset execution tracking
event_stream.add_event(NullObservation('test2'), EventSource.AGENT)
# Give some time for the callbacks to execute
time.sleep(0.5)
# Now all three callbacks should have been executed
assert callback_executed[0] is True, 'First callback should have been executed'
assert callback_executed[1] is True, 'Second callback should have been executed'
assert callback_executed[2] is True, 'Third callback should have been executed'
# Clean up
event_stream.close()
def test_cache_page_partial_retrieval(temp_dir: str):
"""Test retrieving events with start_id and end_id parameters using the cache."""
file_store = get_file_store('local', temp_dir)
+2 -2
View File
@@ -59,7 +59,7 @@ async def test_github_service_fetch_data():
with patch('httpx.AsyncClient', return_value=mock_client):
service = GitHubService(user_id=None, token=SecretStr('test-token'))
_ = await service._fetch_data('https://api.github.com/user')
_ = await service._make_request('https://api.github.com/user')
# Verify the request was made with correct headers
mock_client.get.assert_called_once()
@@ -78,4 +78,4 @@ async def test_github_service_fetch_data():
mock_client.get.return_value = mock_response
with pytest.raises(AuthenticationError):
_ = await service._fetch_data('https://api.github.com/user')
_ = await service._make_request('https://api.github.com/user')