Compare commits

..

14 Commits

Author SHA1 Message Date
openhands
57d07df83f Implement FE methods for GitHub installations and BitBucket workspaces 2025-07-21 20:05:26 +00:00
rohitvinodmalhotra@gmail.com
8f594310fa add endpoints for installations 2025-07-21 15:54:12 -04:00
rohitvinodmalhotra@gmail.com
a41280f3b8 add provider methods for getting installations 2025-07-21 15:50:55 -04:00
rohitvinodmalhotra@gmail.com
385accd267 add bitbucket workspaces 2025-07-21 15:21:22 -04:00
rohitvinodmalhotra@gmail.com
72a462681c add app installation hook 2025-07-21 15:19:19 -04:00
openhands
27caa0b5fd Remove repository filtering by provider for now 2025-07-21 19:08:34 +00:00
openhands
e8cabe6def Add provider dropdown to repository selection 2025-07-21 19:04:32 +00:00
rohitvinodmalhotra@gmail.com
8aa1ae712f update mock 2025-07-21 12:30:17 -04:00
rohitvinodmalhotra@gmail.com
993804dd4f fix tests 2025-07-21 12:25:08 -04:00
rohitvinodmalhotra@gmail.com
d5aaa8a67a add params to endpoint 2025-07-21 12:19:49 -04:00
rohitvinodmalhotra@gmail.com
9cc7823723 fix type + get bitbucket installations 2025-07-21 11:57:14 -04:00
openhands
def357024e Add get_paginated_repos method to BitBucketService 2025-07-21 15:48:52 +00:00
rohitvinodmalhotra@gmail.com
d868eb5dee paginate repos for gitlab 2025-07-21 11:35:10 -04:00
rohitvinodmalhotra@gmail.com
a88f3744da add method for paginated response 2025-07-21 11:20:45 -04:00
101 changed files with 978 additions and 6070 deletions

View File

@@ -1,135 +1,56 @@
# Run evaluation on a PR, after releases, or manually
# Run evaluation on a PR
name: Run Eval
# Runs when a PR is labeled with one of the "run-eval-" labels, after releases, or manually triggered
# Runs when a PR is labeled with one of the "run-eval-" labels
on:
pull_request:
types: [labeled]
release:
types: [published]
workflow_dispatch:
inputs:
branch:
description: 'Branch to evaluate'
required: true
default: 'main'
eval_instances:
description: 'Number of evaluation instances'
required: true
default: '50'
type: choice
options:
- '1'
- '2'
- '50'
- '100'
reason:
description: 'Reason for manual trigger'
required: false
default: ''
env:
# Environment variable for the master GitHub issue number where all evaluation results will be commented
# This should be set to the issue number where you want all evaluation results to be posted
MASTER_EVAL_ISSUE_NUMBER: ${{ vars.MASTER_EVAL_ISSUE_NUMBER || '0' }}
jobs:
trigger-job:
name: Trigger remote eval job
if: ${{ (github.event_name == 'pull_request' && (github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100')) || github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
if: ${{ github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100' }}
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout branch
- name: Checkout PR branch
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.head_ref || (github.event_name == 'workflow_dispatch' && github.event.inputs.branch) || github.ref }}
ref: ${{ github.head_ref }}
- name: Set evaluation parameters
id: eval_params
- name: Trigger remote job
env:
PR_BRANCH: ${{ github.head_ref }}
run: |
REPO_URL="https://github.com/${{ github.repository }}"
echo "Repository URL: $REPO_URL"
echo "PR Branch: $PR_BRANCH"
# Determine branch based on trigger type
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
EVAL_BRANCH="${{ github.head_ref }}"
echo "PR Branch: $EVAL_BRANCH"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
EVAL_BRANCH="${{ github.event.inputs.branch }}"
echo "Manual Branch: $EVAL_BRANCH"
else
# For release events, use the tag name or main branch
EVAL_BRANCH="${{ github.ref_name }}"
echo "Release Branch/Tag: $EVAL_BRANCH"
fi
# Determine evaluation instances based on trigger type
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
EVAL_INSTANCES="1"
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
EVAL_INSTANCES="2"
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
EVAL_INSTANCES="50"
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
EVAL_INSTANCES="100"
fi
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
EVAL_INSTANCES="${{ github.event.inputs.eval_instances }}"
else
# For release events, default to 50 instances
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
EVAL_INSTANCES="1"
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
EVAL_INSTANCES="2"
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
EVAL_INSTANCES="50"
fi
echo "Evaluation instances: $EVAL_INSTANCES"
echo "repo_url=$REPO_URL" >> $GITHUB_OUTPUT
echo "eval_branch=$EVAL_BRANCH" >> $GITHUB_OUTPUT
echo "eval_instances=$EVAL_INSTANCES" >> $GITHUB_OUTPUT
- name: Trigger remote job
run: |
# Determine PR number for the remote evaluation system
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
else
# For non-PR triggers, use the master issue number as PR number
PR_NUMBER="${{ env.MASTER_EVAL_ISSUE_NUMBER }}"
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
EVAL_INSTANCES="100"
fi
curl -X POST \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${{ steps.eval_params.outputs.repo_url }}\", \"github-branch\": \"${{ steps.eval_params.outputs.eval_branch }}\", \"pr-number\": \"${PR_NUMBER}\", \"eval-instances\": \"${{ steps.eval_params.outputs.eval_instances }}\"}}" \
-d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${REPO_URL}\", \"github-branch\": \"${PR_BRANCH}\", \"pr-number\": \"${{ github.event.pull_request.number }}\", \"eval-instances\": \"${EVAL_INSTANCES}\"}}" \
https://api.github.com/repos/All-Hands-AI/evaluation/actions/workflows/create-branch.yml/dispatches
# Send Slack message
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
TRIGGER_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
slack_text="PR $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
elif [[ "${{ github.event_name }}" == "release" ]]; then
TRIGGER_URL="https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}"
slack_text="Release $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
else
TRIGGER_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
slack_text="Manual trigger (${{ github.event.inputs.reason || 'No reason provided' }}) has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances for branch ${{ steps.eval_params.outputs.eval_branch }}..."
fi
PR_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
slack_text="PR $PR_URL has triggered evaluation on $EVAL_INSTANCES instances..."
curl -X POST -H 'Content-type: application/json' --data '{"text":"'"$slack_text"'"}' \
https://hooks.slack.com/services/${{ secrets.SLACK_TOKEN }}
- name: Comment on issue/PR
- name: Comment on PR
uses: KeisukeYamashita/create-comment@v1
with:
# For PR triggers, comment on the PR. For other triggers, comment on the master issue
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || env.MASTER_EVAL_ISSUE_NUMBER }}
unique: false
comment: |
**Evaluation Triggered**
**Trigger:** ${{ github.event_name == 'pull_request' && format('Pull Request #{0}', github.event.pull_request.number) || (github.event_name == 'release' && 'Release') || format('Manual Trigger: {0}', github.event.inputs.reason || 'No reason provided') }}
**Branch:** ${{ steps.eval_params.outputs.eval_branch }}
**Instances:** ${{ steps.eval_params.outputs.eval_instances }}
**Commit:** ${{ github.sha }}
Running evaluation on the specified branch. Once eval is done, the results will be posted here.
Running evaluation on the PR. Once eval is done, the results will be posted.

View File

@@ -1,32 +1,29 @@
#!/bin/bash
echo "Running OpenHands pre-commit hook..."
echo "This hook runs 'make lint' to ensure code quality before committing."
# Store the exit code to return at the end
# This allows us to be additive to existing pre-commit hooks
EXIT_CODE=0
# Run make lint to check both frontend and backend code
echo "Running linting checks with 'make lint'..."
make lint
if [ $? -ne 0 ]; then
echo "Linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Linting checks passed!"
fi
# Check if frontend directory has changed
frontend_changes=$(git diff --cached --name-only | grep "^frontend/")
if [ -n "$frontend_changes" ]; then
echo "Frontend changes detected. Running additional frontend checks..."
echo "Frontend changes detected. Running frontend checks..."
# Check if frontend directory exists
if [ -d "frontend" ]; then
# Change to frontend directory
cd frontend || exit 1
# Run lint:fix
echo "Running npm lint:fix..."
npm run lint:fix
if [ $? -ne 0 ]; then
echo "Frontend linting failed. Please fix the issues before committing."
EXIT_CODE=1
fi
# Run build
echo "Running npm build..."
npm run build
@@ -53,7 +50,7 @@ if [ -n "$frontend_changes" ]; then
echo "Frontend directory not found. Skipping frontend checks."
fi
else
echo "No frontend changes detected. Skipping additional frontend checks."
echo "No frontend changes detected. Skipping frontend checks."
fi
# Run any existing pre-commit hooks that might have been installed by the user

View File

@@ -117,7 +117,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace

View File

@@ -345,7 +345,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace

View File

@@ -226,7 +226,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace

View File

@@ -203,7 +203,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace

View File

@@ -164,7 +164,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace

View File

@@ -19,13 +19,7 @@ describe("AuthModal", () => {
});
it("should render the GitHub and GitLab buttons", () => {
render(
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
providersConfigured={["github", "gitlab"]}
/>,
);
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
@@ -41,13 +35,7 @@ describe("AuthModal", () => {
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
const user = userEvent.setup();
const mockUrl = "https://github.com/login/oauth/authorize";
render(
<AuthModal
githubAuthUrl={mockUrl}
appMode="saas"
providersConfigured={["github"]}
/>,
);
render(<AuthModal githubAuthUrl={mockUrl} appMode="saas" />);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
@@ -64,6 +52,7 @@ describe("AuthModal", () => {
const termsSection = screen.getByTestId("auth-modal-terms-of-service");
expect(termsSection).toBeInTheDocument();
// Check that all text content is present in the paragraph
expect(termsSection).toHaveTextContent(
"AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",

View File

@@ -16,6 +16,8 @@ import { ConversationCard } from "#/components/features/conversation-panel/conve
import { clickOnEditButton } from "./utils";
// We'll use the actual i18next implementation but override the translation function
import { I18nextProvider } from "react-i18next";
import i18n from "i18next";
// Mock the t function to return our custom translations
vi.mock("react-i18next", async () => {
@@ -122,8 +124,7 @@ describe("ConversationCard", () => {
it("should toggle a context menu when clicking the ellipsis button", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
const { rerender } = renderWithProviders(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
@@ -131,8 +132,6 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen={false}
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -141,32 +140,15 @@ describe("ConversationCard", () => {
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
expect(onContextMenuToggle).toHaveBeenCalledWith(true);
// Simulate context menu being opened by parent
rerender(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
screen.getByTestId("context-menu");
await user.click(ellipsisButton);
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
});
it("should call onDelete when the delete button is clicked", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -175,18 +157,18 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const deleteButton = within(menu).getByTestId("delete-button");
await user.click(deleteButton);
expect(onDelete).toHaveBeenCalled();
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
});
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
@@ -216,11 +198,7 @@ describe("ConversationCard", () => {
test("conversation title should call onChangeTitle when changed and blurred", async () => {
const user = userEvent.setup();
let menuOpen = true;
const onContextMenuToggle = vi.fn((isOpen: boolean) => {
menuOpen = isOpen;
});
const { rerender } = renderWithProviders(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
@@ -228,27 +206,10 @@ describe("ConversationCard", () => {
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
onChangeTitle={onChangeTitle}
contextMenuOpen={menuOpen}
onContextMenuToggle={onContextMenuToggle}
/>,
);
await clickOnEditButton(user);
// Re-render with updated state
rerender(
<ConversationCard
onDelete={onDelete}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
onChangeTitle={onChangeTitle}
contextMenuOpen={menuOpen}
onContextMenuToggle={onContextMenuToggle}
/>,
);
const title = screen.getByTestId("conversation-card-title");
expect(title).toBeEnabled();
@@ -266,7 +227,6 @@ describe("ConversationCard", () => {
it("should reset title and not call onChangeTitle when the title is empty", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -275,8 +235,6 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -313,7 +271,6 @@ describe("ConversationCard", () => {
test("clicking the title should not trigger the onClick handler if edit mode", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -322,8 +279,6 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -337,7 +292,6 @@ describe("ConversationCard", () => {
test("clicking the delete button should not trigger the onClick handler", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -346,11 +300,12 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const deleteButton = within(menu).getByTestId("delete-button");
@@ -360,7 +315,7 @@ describe("ConversationCard", () => {
});
it("should show display cost button only when showOptions is true", async () => {
const onContextMenuToggle = vi.fn();
const user = userEvent.setup();
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -369,17 +324,21 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Wait for context menu to appear
const menu = await screen.findByTestId("context-menu");
expect(
within(menu).queryByTestId("display-cost-button"),
).not.toBeInTheDocument();
// Close menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onDelete={onDelete}
@@ -389,11 +348,12 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
// Open menu again
await user.click(ellipsisButton);
// Wait for context menu to appear and check for display cost button
const newMenu = await screen.findByTestId("context-menu");
within(newMenu).getByTestId("display-cost-button");
@@ -401,7 +361,6 @@ describe("ConversationCard", () => {
it("should show metrics modal when clicking the display cost button", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -411,11 +370,12 @@ describe("ConversationCard", () => {
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
showOptions
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const displayCostButton = within(menu).getByTestId("display-cost-button");
@@ -426,7 +386,7 @@ describe("ConversationCard", () => {
});
it("should not display the edit or delete options if the handler is not provided", async () => {
const onContextMenuToggle = vi.fn();
const user = userEvent.setup();
const { rerender } = renderWithProviders(
<ConversationCard
onClick={onClick}
@@ -434,15 +394,19 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = await screen.findByTestId("context-menu");
expect(within(menu).queryByTestId("edit-button")).toBeInTheDocument();
expect(within(menu).queryByTestId("delete-button")).not.toBeInTheDocument();
// toggle to hide the context menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onClick={onClick}
@@ -450,11 +414,10 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
await user.click(ellipsisButton);
const newMenu = await screen.findByTestId("context-menu");
expect(
within(newMenu).queryByTestId("edit-button"),

View File

@@ -34,7 +34,7 @@
"jose": "^6.0.12",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.257.1",
"posthog-js": "^1.257.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -68,7 +68,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.1.0",
"@types/node": "^24.0.14",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -82,11 +82,11 @@
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-i18next": "^6.1.3",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-i18next": "^6.1.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.1.4",
@@ -6160,9 +6160,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"version": "24.0.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz",
"integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==",
"devOptional": true,
"dependencies": {
"undici-types": "~7.8.0"
@@ -9017,10 +9017,11 @@
}
},
"node_modules/eslint-config-prettier": {
"version": "10.1.8",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"version": "10.1.5",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz",
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -9082,10 +9083,11 @@
}
},
"node_modules/eslint-plugin-i18next": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.3.tgz",
"integrity": "sha512-z/h4oBRd9wI1ET60HqcLSU6XPeAh/EPOrBBTyCdkWeMoYrWAaUVA+DOQkWTiNIyCltG4NTmy62SQisVXxoXurw==",
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.2.tgz",
"integrity": "sha512-hvTmws4kouNHkk314+9MHNj+RQmsqrkejWhTXGlRC0j8H+EXq2qDRLe6UqIjrFZo7/ogyd4btuqsnKCBi8wHbw==",
"dev": true,
"license": "ISC",
"dependencies": {
"lodash": "^4.17.21",
"requireindex": "~1.1.0"
@@ -9250,10 +9252,11 @@
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.3.tgz",
"integrity": "sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==",
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.1.tgz",
"integrity": "sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
"synckit": "^0.11.7"
@@ -14265,9 +14268,10 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.257.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.257.1.tgz",
"integrity": "sha512-29kk3IO/LkPQ8E1cds6a2sWr5iN4BovgL+EMzRK9hQXbI6D3FJnQ7zLU6EUpktt6pHnqGpfO3BTEcflcDYkHBg==",
"version": "1.257.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.257.0.tgz",
"integrity": "sha512-Ujg9RGtWVCu+4tmlRpALSy2ZOZI6JtieSYXIDDdgMWm167KYKvTtbMPHdoBaPWcNu0Km+1hAIBnQFygyn30KhA==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"core-js": "^3.38.1",
"fflate": "^0.4.8",

View File

@@ -33,7 +33,7 @@
"jose": "^6.0.12",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.257.1",
"posthog-js": "^1.257.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -92,7 +92,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.1.0",
"@types/node": "^24.0.14",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -106,11 +106,11 @@
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-i18next": "^6.1.3",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-i18next": "^6.1.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.1.4",

View File

@@ -13,13 +13,11 @@ import {
GitChange,
GetMicroagentsResponse,
GetMicroagentPromptResponse,
CreateMicroagent,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { GitUser, GitRepository, Branch } from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { RepositoryMicroagent } from "#/types/microagent-management";
class OpenHands {
private static currentConversation: Conversation | null = null;
@@ -252,28 +250,6 @@ class OpenHands {
return data.results;
}
static async searchConversations(
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 20,
): Promise<Conversation[]> {
const params = new URLSearchParams();
params.append("limit", limit.toString());
if (selectedRepository) {
params.append("selected_repository", selectedRepository);
}
if (conversationTrigger) {
params.append("conversation_trigger", conversationTrigger);
}
const { data } = await openHands.get<ResultSet<Conversation>>(
`/api/conversations?${params.toString()}`,
);
return data.results;
}
static async deleteUserConversation(conversationId: string): Promise<void> {
await openHands.delete(`/api/conversations/${conversationId}`);
}
@@ -285,7 +261,6 @@ class OpenHands {
suggested_task?: SuggestedTask,
selected_branch?: string,
conversationInstructions?: string,
createMicroagent?: CreateMicroagent,
): Promise<Conversation> {
const body = {
repository: selectedRepository,
@@ -294,7 +269,6 @@ class OpenHands {
initial_user_msg: initialUserMsg,
suggested_task,
conversation_instructions: conversationInstructions,
create_microagent: createMicroagent,
};
const { data } = await openHands.post<Conversation>(
@@ -490,22 +464,6 @@ class OpenHands {
return data;
}
/**
* Get the available microagents for a specific repository
* @param owner The repository owner
* @param repo The repository name
* @returns The available microagents for the repository
*/
static async getRepositoryMicroagents(
owner: string,
repo: string,
): Promise<RepositoryMicroagent[]> {
const { data } = await openHands.get<RepositoryMicroagent[]>(
`/api/user/repository/${owner}/${repo}/microagents`,
);
return data;
}
static async getMicroagentPrompt(
conversationId: string,
eventId: number,
@@ -531,6 +489,24 @@ class OpenHands {
return data;
}
/**
* Get the GitHub user installation IDs
* @returns List of GitHub installation IDs
*/
static async getGitHubUserInstallationIds(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/github/installations");
return data;
}
/**
* Get the BitBucket workspaces
* @returns List of BitBucket workspaces
*/
static async getBitBucketWorkspaces(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/bitbucket/installations");
return data;
}
}
export default OpenHands;

View File

@@ -79,11 +79,7 @@ export interface RepositorySelection {
git_provider: Provider | null;
}
export type ConversationTrigger =
| "resolver"
| "gui"
| "suggested_task"
| "microagent_management";
export type ConversationTrigger = "resolver" | "gui" | "suggested_task";
export interface Conversation {
conversation_id: string;
@@ -98,7 +94,6 @@ export interface Conversation {
trigger?: ConversationTrigger;
url: string | null;
session_api_key: string | null;
pr_number?: number[] | null;
}
export interface ResultSet<T> {
@@ -138,9 +133,3 @@ export interface GetMicroagentPromptResponse {
status: string;
prompt: string;
}
export interface CreateMicroagent {
repo: string;
git_provider?: Provider;
title?: string;
}

View File

@@ -13,7 +13,6 @@ interface ControlsProps {
export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
const { data: conversation } = useActiveConversation();
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
return (
<div className="flex flex-col gap-2 md:items-center md:justify-between md:flex-row">
@@ -38,8 +37,6 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
}}
conversationStatus={conversation?.status}
conversationId={conversation?.conversation_id}
contextMenuOpen={contextMenuOpen}
onContextMenuToggle={setContextMenuOpen}
/>
</div>
);

View File

@@ -35,8 +35,6 @@ interface ConversationCardProps {
conversationStatus?: ConversationStatus;
variant?: "compact" | "default";
conversationId?: string; // Optional conversation ID for VS Code URL
contextMenuOpen?: boolean;
onContextMenuToggle?: (isOpen: boolean) => void;
}
const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
@@ -57,11 +55,10 @@ export function ConversationCard({
conversationStatus = "STOPPED",
variant = "default",
conversationId,
contextMenuOpen = false,
onContextMenuToggle,
}: ConversationCardProps) {
const { t } = useTranslation();
const { parsedEvents } = useWsClient();
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
@@ -104,21 +101,21 @@ export function ConversationCard({
event.preventDefault();
event.stopPropagation();
onDelete?.();
onContextMenuToggle?.(false);
setContextMenuVisible(false);
};
const handleStop = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onStop?.();
onContextMenuToggle?.(false);
setContextMenuVisible(false);
};
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setTitleMode("edit");
onContextMenuToggle?.(false);
setContextMenuVisible(false);
};
const handleDownloadViaVSCode = async (
@@ -144,7 +141,7 @@ export function ConversationCard({
}
}
onContextMenuToggle?.(false);
setContextMenuVisible(false);
};
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -227,15 +224,15 @@ export function ConversationCard({
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onContextMenuToggle?.(!contextMenuOpen);
setContextMenuVisible((prev) => !prev);
}}
/>
</div>
)}
<div className="relative">
{contextMenuOpen && (
{contextMenuVisible && (
<ConversationCardContextMenu
onClose={() => onContextMenuToggle?.(false)}
onClose={() => setContextMenuVisible(false)}
onDelete={onDelete && handleDelete}
onStop={
conversationStatus !== "STOPPED"

View File

@@ -36,9 +36,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const [selectedConversationId, setSelectedConversationId] = React.useState<
string | null
>(null);
const [openContextMenuId, setOpenContextMenuId] = React.useState<
string | null
>(null);
const { data: conversations, isFetching, error } = useUserConversations();
@@ -147,10 +144,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
createdAt={project.created_at}
conversationStatus={project.status}
conversationId={project.conversation_id}
contextMenuOpen={openContextMenuId === project.conversation_id}
onContextMenuToggle={(isOpen) =>
setOpenContextMenuId(isOpen ? project.conversation_id : null)
}
/>
)}
</NavLink>

View File

@@ -10,6 +10,9 @@ import { BrandButton } from "../settings/brand-button";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useDebounce } from "#/hooks/use-debounce";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { useUserProviders } from "#/hooks/use-user-providers";
import { Provider } from "#/types/settings";
import { SettingsDropdownInput } from "../settings/settings-dropdown-input";
import {
RepositoryDropdown,
RepositoryLoadingState,
@@ -32,8 +35,10 @@ export function RepositorySelectionForm({
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
null,
);
const [selectedProvider, setSelectedProvider] = React.useState<Provider | null>(null);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = React.useRef<boolean>(false);
const { providers } = useUserProviders();
const {
data: repositories,
isLoading: isLoadingRepositories,
@@ -56,6 +61,13 @@ export function RepositorySelectionForm({
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery);
// Auto-select provider if there's only one
React.useEffect(() => {
if (providers.length === 1 && !selectedProvider) {
setSelectedProvider(providers[0]);
}
}, [providers, selectedProvider]);
// Auto-select main or master branch if it exists, but only if the branch wasn't manually cleared
React.useEffect(() => {
if (
@@ -83,8 +95,10 @@ export function RepositorySelectionForm({
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
// Use all repositories without filtering by provider for now
const allRepositories = repositories?.concat(searchedRepos || []);
const repositoriesItems = allRepositories?.map((repo) => ({
const repositoriesItems = (allRepositories || []).map((repo) => ({
key: repo.id,
label: decodeURIComponent(repo.full_name),
}));
@@ -94,6 +108,14 @@ export function RepositorySelectionForm({
label: branch.name,
}));
// Create provider dropdown items
const providerItems = React.useMemo(() => {
return providers.map(provider => ({
key: provider,
label: provider.charAt(0).toUpperCase() + provider.slice(1), // Capitalize first letter
}));
}, [providers]);
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
if (selectedRepo) onRepoSelection(selectedRepo);
@@ -102,6 +124,14 @@ export function RepositorySelectionForm({
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
};
const handleProviderSelection = (key: React.Key | null) => {
const provider = key as Provider | null;
setSelectedProvider(provider);
setSelectedRepository(null); // Reset repository selection when provider changes
setSelectedBranch(null); // Reset branch selection when provider changes
onRepoSelection(null); // Reset parent component's selected repo
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
@@ -133,6 +163,26 @@ export function RepositorySelectionForm({
}
};
// Render the provider dropdown
const renderProviderSelector = () => {
// Only render if there are multiple providers
if (providers.length <= 1) {
return null;
}
return (
<SettingsDropdownInput
testId="provider-dropdown"
name="provider-dropdown"
placeholder="Select Provider"
items={providerItems}
wrapperClassName="max-w-[500px]"
onSelectionChange={handleProviderSelection}
selectedKey={selectedProvider || undefined}
/>
);
};
// Render the appropriate UI based on the loading/error state
const renderRepositorySelector = () => {
if (isLoadingRepositories) {
@@ -143,11 +193,15 @@ export function RepositorySelectionForm({
return <RepositoryErrorState />;
}
// For now, don't disable the repo dropdown based on provider selection
const isDisabled = false;
return (
<RepositoryDropdown
items={repositoriesItems || []}
onSelectionChange={handleRepoSelection}
onInputChange={handleRepoInputChange}
isDisabled={isDisabled}
defaultFilter={(textValue, inputValue) => {
if (!inputValue) return true;
@@ -195,8 +249,8 @@ export function RepositorySelectionForm({
return (
<div className="flex flex-col gap-4">
{renderProviderSelector()}
{renderRepositorySelector()}
{renderBranchSelector()}
<BrandButton

View File

@@ -1,8 +1,7 @@
import React, { ReactNode } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
export interface BranchDropdownProps {
items: { key: React.Key; label: string }[];
@@ -10,8 +9,6 @@ export interface BranchDropdownProps {
onInputChange: (value: string) => void;
isDisabled: boolean;
selectedKey?: string;
wrapperClassName?: string;
label?: ReactNode;
}
export function BranchDropdown({
@@ -20,8 +17,6 @@ export function BranchDropdown({
onInputChange,
isDisabled,
selectedKey,
wrapperClassName,
label,
}: BranchDropdownProps) {
const { t } = useTranslation();
@@ -31,12 +26,11 @@ export function BranchDropdown({
name="branch-dropdown"
placeholder={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
items={items}
wrapperClassName={cn("max-w-[500px]", wrapperClassName)}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
isDisabled={isDisabled}
selectedKey={selectedKey}
label={label}
/>
);
}

View File

@@ -1,19 +1,12 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
interface BranchErrorStateProps {
wrapperClassName?: string;
}
export function BranchErrorState({ wrapperClassName }: BranchErrorStateProps) {
export function BranchErrorState() {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-error"
className={cn(
"flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500",
wrapperClassName,
)}
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500"
>
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_BRANCHES")}</span>
</div>

View File

@@ -1,22 +1,13 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { cn } from "#/utils/utils";
interface BranchLoadingStateProps {
wrapperClassName?: string;
}
export function BranchLoadingState({
wrapperClassName,
}: BranchLoadingStateProps) {
export function BranchLoadingState() {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-loading"
className={cn(
"flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm",
wrapperClassName,
)}
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm"
>
<Spinner size="sm" />
<span className="text-sm">{t("HOME$LOADING_BRANCHES")}</span>

View File

@@ -8,6 +8,7 @@ export interface RepositoryDropdownProps {
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
defaultFilter?: (textValue: string, inputValue: string) => boolean;
isDisabled?: boolean;
}
export function RepositoryDropdown({
@@ -15,6 +16,7 @@ export function RepositoryDropdown({
onSelectionChange,
onInputChange,
defaultFilter,
isDisabled = false,
}: RepositoryDropdownProps) {
const { t } = useTranslation();
@@ -22,12 +24,13 @@ export function RepositoryDropdown({
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder={t(I18nKey.REPOSITORY$SELECT_REPO)}
placeholder={isDisabled ? t("Please select a provider first") : t(I18nKey.REPOSITORY$SELECT_REPO)}
items={items}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
defaultFilter={defaultFilter}
isDisabled={isDisabled}
/>
);
}

View File

@@ -1,26 +0,0 @@
import { GitProviderIcon } from "#/components/shared/git-provider-icon";
import { GitRepository } from "#/types/git";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
interface MicroagentManagementAccordionTitleProps {
repository: GitRepository;
}
export function MicroagentManagementAccordionTitle({
repository,
}: MicroagentManagementAccordionTitleProps) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitProviderIcon gitProvider={repository.git_provider} />
<div
className="text-white text-base font-normal truncate max-w-[150px]"
title={repository.full_name}
>
{repository.full_name}
</div>
</div>
<MicroagentManagementAddMicroagentButton repository={repository} />
</div>
);
}

View File

@@ -1,20 +1,10 @@
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { I18nKey } from "#/i18n/declaration";
import {
setAddMicroagentModalVisible,
setSelectedRepository,
} from "#/state/microagent-management-slice";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
import { RootState } from "#/store";
import { GitRepository } from "#/types/git";
interface MicroagentManagementAddMicroagentButtonProps {
repository: GitRepository;
}
export function MicroagentManagementAddMicroagentButton({
repository,
}: MicroagentManagementAddMicroagentButtonProps) {
export function MicroagentManagementAddMicroagentButton() {
const { t } = useTranslation();
const { addMicroagentModalVisible } = useSelector(
@@ -23,10 +13,8 @@ export function MicroagentManagementAddMicroagentButton({
const dispatch = useDispatch();
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const handleClick = () => {
dispatch(setAddMicroagentModalVisible(!addMicroagentModalVisible));
dispatch(setSelectedRepository(repository));
};
return (

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { FaCircleInfo } from "react-icons/fa6";
@@ -10,155 +10,30 @@ import { RootState } from "#/store";
import XIcon from "#/icons/x.svg?react";
import { cn } from "#/utils/utils";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
import { MicroagentFormData } from "#/types/microagent-management";
import { Branch, GitRepository } from "#/types/git";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import {
BranchDropdown,
BranchLoadingState,
BranchErrorState,
} from "../home/repository-selection";
interface MicroagentManagementAddMicroagentModalProps {
onConfirm: (formData: MicroagentFormData) => void;
onConfirm: () => void;
onCancel: () => void;
isLoading: boolean;
}
export function MicroagentManagementAddMicroagentModal({
onConfirm,
onCancel,
isLoading = false,
}: MicroagentManagementAddMicroagentModalProps) {
const { t } = useTranslation();
const [triggers, setTriggers] = useState<string[]>([]);
const [query, setQuery] = useState<string>("");
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = useRef<boolean>(false);
const {
data: branches,
isLoading: isLoadingBranches,
isError: isBranchesError,
} = useRepositoryBranches(selectedRepository?.full_name || null);
const branchesItems = branches?.map((branch) => ({
key: branch.name,
label: branch.name,
}));
// Auto-select main or master branch if it exists.
useEffect(() => {
if (
branches &&
branches.length > 0 &&
!selectedBranch &&
!isLoadingBranches
) {
// Look for main or master branch
const mainBranch = branches.find((branch) => branch.name === "main");
const masterBranch = branches.find((branch) => branch.name === "master");
// Select main if it exists, otherwise select master if it exists
if (mainBranch) {
setSelectedBranch(mainBranch);
} else if (masterBranch) {
setSelectedBranch(masterBranch);
}
}
}, [branches, isLoadingBranches, selectedBranch]);
const modalTitle = selectedRepository
? `${t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO)} ${(selectedRepository as GitRepository).full_name}`
? `${t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO)} ${selectedRepository}`
: t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT);
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!query.trim()) {
return;
}
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
});
};
const handleConfirm = () => {
if (!query.trim()) {
return;
}
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
});
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
// Reset the manually cleared flag when a branch is explicitly selected
branchManuallyClearedRef.current = false;
};
const handleBranchInputChange = (value: string) => {
// Clear the selected branch if the input is empty or contains only whitespace
// This fixes the issue where users can't delete the entire default branch name
if (value === "" || value.trim() === "") {
setSelectedBranch(null);
// Set the flag to indicate that the branch was manually cleared
branchManuallyClearedRef.current = true;
} else {
// Reset the flag when the user starts typing again
branchManuallyClearedRef.current = false;
}
};
// Render the appropriate UI for branch selector based on the loading/error state
const renderBranchSelector = () => {
if (!selectedRepository) {
return (
<BranchDropdown
items={[]}
onSelectionChange={() => {}}
onInputChange={() => {}}
isDisabled
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
}
if (isLoadingBranches) {
return <BranchLoadingState wrapperClassName="max-w-full w-full" />;
}
if (isBranchesError) {
return <BranchErrorState wrapperClassName="max-w-full w-full" />;
}
return (
<BranchDropdown
items={branchesItems || []}
onSelectionChange={handleBranchSelection}
onInputChange={handleBranchInputChange}
isDisabled={false}
selectedKey={selectedBranch?.name}
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
};
return (
@@ -189,7 +64,6 @@ export function MicroagentManagementAddMicroagentModal({
onSubmit={onSubmit}
className="flex flex-col gap-6 w-full"
>
{renderBranchSelector()}
<label
htmlFor="query-input"
className="flex flex-col gap-2 w-full text-sm font-normal"
@@ -199,8 +73,6 @@ export function MicroagentManagementAddMicroagentModal({
required
data-testid="query-input"
name="query-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(I18nKey.MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO)}
rows={6}
className={cn(
@@ -208,6 +80,19 @@ export function MicroagentManagementAddMicroagentModal({
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
<div className="flex items-center gap-2 text-[11px] font-normal text-white leading-[16px]">
<span className="font-semibold">
{t(I18nKey.COMMON$FOR_EXAMPLE)}:
</span>
<span className="underline">
{t(I18nKey.COMMON$TEST_DB_MIGRATION)}
</span>
<span className="underline">{t(I18nKey.COMMON$RUN_TEST)}</span>
<span className="underline">{t(I18nKey.COMMON$RUN_APP)}</span>
<span className="underline">
{t(I18nKey.COMMON$LEARN_FILE_STRUCTURE)}
</span>
</div>
</label>
<label
htmlFor="trigger-input"
@@ -244,26 +129,17 @@ export function MicroagentManagementAddMicroagentModal({
type="button"
variant="secondary"
onClick={onCancel}
testId="cancel-button"
data-testid="cancel-button"
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
<BrandButton
type="button"
variant="primary"
onClick={handleConfirm}
testId="confirm-button"
isDisabled={
!query.trim() ||
isLoading ||
isLoadingBranches ||
!selectedBranch ||
isBranchesError
}
onClick={onConfirm}
data-testid="confirm-button"
>
{isLoading || isLoadingBranches
? t(I18nKey.HOME$LOADING)
: t(I18nKey.MICROAGENT$LAUNCH)}
{t(I18nKey.MICROAGENT$LAUNCH)}
</BrandButton>
</div>
</ModalBody>

View File

@@ -1,230 +0,0 @@
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementSidebar } from "./microagent-management-sidebar";
import { MicroagentManagementMain } from "./microagent-management-main";
import { MicroagentManagementAddMicroagentModal } from "./microagent-management-add-microagent-modal";
import { RootState } from "#/store";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple";
import { MicroagentFormData } from "#/types/microagent-management";
import { AgentState } from "#/types/agent-state";
import { getPR, getProviderName, getPRShort } from "#/utils/utils";
import {
isOpenHandsEvent,
isAgentStateChangeObservation,
isFinishAction,
} from "#/types/core/guards";
import { GitRepository } from "#/types/git";
import { queryClient } from "#/query-client-config";
import { Provider } from "#/types/settings";
// Handle error events
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
const shouldInvalidateConversationsList = (currentSocketEvent: unknown) => {
const hasError =
isErrorEvent(currentSocketEvent) || isAgentStatusError(currentSocketEvent);
const hasStateChanged =
isOpenHandsEvent(currentSocketEvent) &&
isAgentStateChangeObservation(currentSocketEvent);
const hasFinished =
isOpenHandsEvent(currentSocketEvent) && isFinishAction(currentSocketEvent);
return hasError || hasStateChanged || hasFinished;
};
const getConversationInstructions = (
repositoryName: string,
formData: MicroagentFormData,
pr: string,
prShort: string,
gitProvider: Provider,
) => `Create a microagent for the repository ${repositoryName} by following the steps below:
- Step 1: Create a markdown file inside the .openhands/microagents folder with the name of the microagent (The microagent must be created in the .openhands/microagents folder and should be able to perform the described task when triggered).
- Step 2: Update the markdown file with the content below:
${
formData.triggers &&
formData.triggers.length > 0 &&
`
---
triggers:
${formData.triggers.map((trigger: string) => ` - ${trigger}`).join("\n")}
---
`
}
${formData.query}
- Step 3: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
- Step 4: Please push the changes to your branch on ${getProviderName(gitProvider)} and create a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.
`;
export function MicroagentManagementContent() {
// Responsive width state
const [width, setWidth] = useState(window.innerWidth);
const { addMicroagentModalVisible, selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const { createConversationAndSubscribe, isPending } =
useCreateConversationAndSubscribeMultiple();
function handleResize() {
setWidth(window.innerWidth);
}
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
const hideAddMicroagentModal = () => {
dispatch(setAddMicroagentModalVisible(false));
};
// Reusable function to invalidate conversations list for a repository
const invalidateConversationsList = React.useCallback(
(repositoryName: string) => {
queryClient.invalidateQueries({
queryKey: [
"conversations",
"search",
repositoryName,
"microagent_management",
],
});
},
[],
);
const handleMicroagentEvent = React.useCallback(
(socketEvent: unknown) => {
// Get repository name from selectedRepository for invalidation
const repositoryName =
selectedRepository && typeof selectedRepository === "object"
? (selectedRepository as GitRepository).full_name
: "";
if (shouldInvalidateConversationsList(socketEvent)) {
invalidateConversationsList(repositoryName);
}
},
[invalidateConversationsList, selectedRepository],
);
const handleCreateMicroagent = (formData: MicroagentFormData) => {
if (!selectedRepository || typeof selectedRepository !== "object") {
return;
}
// Use the GitRepository properties
const repository = selectedRepository as GitRepository;
const repositoryName = repository.full_name;
const gitProvider = repository.git_provider;
const isGitLab = gitProvider === "gitlab";
const pr = getPR(isGitLab);
const prShort = getPRShort(isGitLab);
// Create conversation instructions for microagent generation
const conversationInstructions = getConversationInstructions(
repositoryName,
formData,
pr,
prShort,
gitProvider,
);
// Create the CreateMicroagent object
const createMicroagent = {
repo: repositoryName,
git_provider: gitProvider,
title: formData.query,
};
createConversationAndSubscribe({
query: conversationInstructions,
conversationInstructions,
repository: {
name: repositoryName,
branch: formData.selectedBranch,
gitProvider,
},
createMicroagent,
onSuccessCallback: () => {
hideAddMicroagentModal();
// Invalidate conversations list to fetch the latest conversations for this repository
invalidateConversationsList(repositoryName);
// Also invalidate microagents list to fetch the latest microagents
// Extract owner and repo from full_name (format: "owner/repo")
const [owner, repo] = repositoryName.split("/");
queryClient.invalidateQueries({
queryKey: ["repository-microagents", owner, repo],
});
hideAddMicroagentModal();
},
onEventCallback: (event: unknown) => {
// Handle conversation events for real-time status updates
handleMicroagentEvent(event);
},
});
};
if (width < 1024) {
return (
<div className="w-full h-full flex flex-col gap-6">
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] max-h-[494px] min-h-[494px]">
<MicroagentManagementSidebar isSmallerScreen />
</div>
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] flex-1 min-h-[494px]">
<MicroagentManagementMain />
</div>
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={handleCreateMicroagent}
onCancel={hideAddMicroagentModal}
isLoading={isPending}
/>
)}
</div>
);
}
return (
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E] overflow-hidden">
<MicroagentManagementSidebar />
<div className="flex-1">
<MicroagentManagementMain />
</div>
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={handleCreateMicroagent}
onCancel={hideAddMicroagentModal}
isLoading={isPending}
/>
)}
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementConversationStopped() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}

View File

@@ -1,19 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementDefault() {
const { t } = useTranslation();
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#F9FBFE] text-xl font-bold pb-4">
{t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)}
</div>
<div className="text-white text-sm font-normal text-center max-w-[455px]">
{t(
I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES,
)}
</div>
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementError() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$ERROR)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}

View File

@@ -1,52 +1,29 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RootState } from "#/store";
import { MicroagentManagementDefault } from "./microagent-management-default";
import { MicroagentManagementOpeningPr } from "./microagent-management-opening-pr";
import { MicroagentManagementReviewPr } from "./microagent-management-review-pr";
import { MicroagentManagementViewMicroagent } from "./microagent-management-view-microagent";
import { MicroagentManagementError } from "./microagent-management-error";
import { MicroagentManagementConversationStopped } from "./microagent-management-conversation-stopped";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementMain() {
const { selectedMicroagentItem } = useSelector(
const { t } = useTranslation();
const { selectedMicroagent } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent, conversation } = selectedMicroagentItem ?? {};
if (microagent) {
return <MicroagentManagementViewMicroagent />;
if (!selectedMicroagent) {
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#F9FBFE] text-xl font-bold pb-4">
{t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)}
</div>
<div className="text-white text-sm font-normal text-center max-w-[455px]">
{t(
I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES,
)}
</div>
</div>
);
}
if (conversation) {
if (conversation.pr_number && conversation.pr_number.length > 0) {
return <MicroagentManagementReviewPr />;
}
const isConversationStarting =
conversation.status === "STARTING" ||
conversation.runtime_status === "STATUS$STARTING_RUNTIME";
const isConversationOpeningPr =
conversation.status === "RUNNING" &&
conversation.runtime_status === "STATUS$READY";
if (isConversationStarting || isConversationOpeningPr) {
return <MicroagentManagementOpeningPr />;
}
if (conversation.runtime_status === "STATUS$ERROR") {
return <MicroagentManagementError />;
}
if (
conversation.status === "STOPPED" ||
conversation.runtime_status === "STATUS$STOPPED"
) {
return <MicroagentManagementConversationStopped />;
}
return <MicroagentManagementDefault />;
}
return <MicroagentManagementDefault />;
return null;
}

View File

@@ -1,142 +1,32 @@
import { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { formatDateMMDDYYYY } from "#/utils/format-time-delta";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { Conversation } from "#/api/open-hands.types";
import {
setSelectedMicroagentItem,
setSelectedRepository,
} from "#/state/microagent-management-slice";
import { RootState } from "#/store";
import { cn } from "#/utils/utils";
import { GitRepository } from "#/types/git";
export interface Microagent {
id: string;
name: string;
repositoryUrl: string;
createdAt: string;
}
interface MicroagentManagementMicroagentCardProps {
microagent?: RepositoryMicroagent;
conversation?: Conversation;
repository: GitRepository;
microagent: Microagent;
}
export function MicroagentManagementMicroagentCard({
microagent,
conversation,
repository,
}: MicroagentManagementMicroagentCardProps) {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const {
status: conversationStatus,
runtime_status: runtimeStatus,
pr_number: prNumber,
} = conversation ?? {};
// Format the repository URL to point to the microagent file
const microagentFilePath = microagent
? `.openhands/microagents/${microagent.name}`
: "";
// Format the createdAt date using MM/DD/YYYY format
const formattedCreatedAt = useMemo(() => {
if (microagent) {
return formatDateMMDDYYYY(new Date(microagent.created_at));
}
if (conversation) {
return formatDateMMDDYYYY(new Date(conversation.created_at));
}
return "";
}, [microagent, conversation]);
const hasPr = !!(prNumber && prNumber.length > 0);
// Helper function to get status text
const statusText = useMemo(() => {
if (hasPr) {
return t(I18nKey.COMMON$READY_FOR_REVIEW);
}
if (
conversationStatus === "STARTING" ||
runtimeStatus === "STATUS$STARTING_RUNTIME"
) {
return t(I18nKey.COMMON$STARTING);
}
if (
conversationStatus === "STOPPED" ||
runtimeStatus === "STATUS$STOPPED"
) {
return t(I18nKey.COMMON$STOPPED);
}
if (runtimeStatus === "STATUS$ERROR") {
return t(I18nKey.MICROAGENT$STATUS_ERROR);
}
if (conversationStatus === "RUNNING" && runtimeStatus === "STATUS$READY") {
return t(I18nKey.MICROAGENT$STATUS_OPENING_PR);
}
return "";
}, [conversationStatus, runtimeStatus, t, hasPr]);
const cardTitle = microagent?.name ?? conversation?.title;
const isCardSelected = useMemo(() => {
if (microagent && selectedMicroagentItem?.microagent) {
return selectedMicroagentItem.microagent.name === microagent.name;
}
if (conversation && selectedMicroagentItem?.conversation) {
return (
selectedMicroagentItem.conversation.conversation_id ===
conversation.conversation_id
);
}
return false;
}, [microagent, conversation, selectedMicroagentItem]);
const onMicroagentCardClicked = () => {
dispatch(
setSelectedMicroagentItem(
microagent
? {
microagent,
conversation: null,
}
: {
microagent: null,
conversation,
},
),
);
dispatch(setSelectedRepository(repository));
};
return (
<div
className={cn(
"rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300",
isCardSelected && "bg-[#ffffff33] border-[#C9B974]",
)}
onClick={onMicroagentCardClicked}
>
<div className="flex flex-col items-start gap-2">
{statusText && (
<div className="px-[6px] py-[2px] text-[11px] font-medium bg-[#C9B97433] text-white rounded-2xl">
{statusText}
</div>
)}
<div className="text-white text-[16px] font-semibold">{cardTitle}</div>
{!!microagent && (
<div className="text-white text-sm font-normal">
{microagentFilePath}
</div>
)}
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
</div>
<div className="rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300">
<div className="text-white text-[16px] font-semibold">
{microagent.name}
</div>
<div className="text-white text-sm font-normal">
{microagent.repositoryUrl}
</div>
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {microagent.createdAt}
</div>
</div>
);

View File

@@ -0,0 +1,38 @@
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
export function MicroagentManagementMicroagents() {
const microagents = [
{
id: "no-comments",
name: "No comments",
repositoryUrl: "fairwinds/polaris/Repo Overview",
createdAt: "05/30/2025",
},
{
id: "tell-me-a-joke",
name: "Tell me a joke",
repositoryUrl: ".openhands/microagents/Repo Overview",
createdAt: "05/30/2025",
},
];
const numberOfMicroagents = microagents.length;
if (numberOfMicroagents === 0) {
return null;
}
return (
<div>
<div className="flex items-center justify-end pb-4">
<MicroagentManagementAddMicroagentButton />
</div>
{microagents.map((microagent) => (
<div key={microagent.id} className="pb-4">
<MicroagentManagementMicroagentCard microagent={microagent} />
</div>
))}
</div>
);
}

View File

@@ -1,22 +0,0 @@
import { FaCircleInfo } from "react-icons/fa6";
interface MicroagentManagementNoRepositoriesProps {
title: string;
documentationUrl: string;
}
export function MicroagentManagementNoRepositories({
title,
documentationUrl,
}: MicroagentManagementNoRepositoriesProps) {
return (
<div className="flex items-center justify-center pt-10">
<div className="flex items-center gap-2">
<h2 className="text-white text-sm font-medium">{title}</h2>
<a href={documentationUrl} target="_blank" rel="noopener noreferrer">
<FaCircleInfo className="text-primary" />
</a>
</div>
</div>
);
}

View File

@@ -1,47 +0,0 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementOpeningPr() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-semibold pb-2">
{t(I18nKey.COMMON$WORKING_ON_IT)}!
</div>
<div className="text-[#ffffff99] text-[18px] font-normal text-center max-w-[518px] pb-[22px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import {
Microagent,
MicroagentManagementMicroagentCard,
} from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
export interface RepoMicroagent {
id: string;
repositoryName: string;
repositoryUrl: string;
microagents: Microagent[];
}
interface MicroagentManagementRepoMicroagentProps {
repoMicroagent: RepoMicroagent;
}
export function MicroagentManagementRepoMicroagent({
repoMicroagent,
}: MicroagentManagementRepoMicroagentProps) {
const { microagents } = repoMicroagent;
const numberOfMicroagents = microagents.length;
return (
<div className="pb-12">
<div className="flex items-center justify-between pb-4">
<div className="text-white text-base font-normal">
{repoMicroagent.repositoryName}
</div>
<MicroagentManagementAddMicroagentButton />
</div>
{numberOfMicroagents === 0 && (
<MicroagentManagementLearnThisRepo
repositoryUrl={repoMicroagent.repositoryUrl}
/>
)}
{numberOfMicroagents > 0 && (
<>
{microagents.map((microagent) => (
<div key={microagent.id} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard microagent={microagent} />
</div>
))}
</>
)}
</div>
);
}

View File

@@ -1,122 +1,42 @@
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { GitRepository } from "#/types/git";
import { getGitProviderBaseUrl } from "#/utils/utils";
import { RootState } from "#/store";
import { setSelectedMicroagentItem } from "#/state/microagent-management-slice";
import { MicroagentManagementRepoMicroagent } from "./microagent-management-repo-microagent";
interface MicroagentManagementRepoMicroagentsProps {
repository: GitRepository;
}
export function MicroagentManagementRepoMicroagents() {
const repoMicroagents = [
{
id: "rbren/rss-parser",
repositoryName: "rbren/rss-parser",
repositoryUrl: "https://github.com/rbren/rss-parser",
microagents: [],
},
{
id: "fairwinds/polaris",
repositoryName: "fairwinds/polaris",
repositoryUrl: "https://github.com/fairwinds/polaris",
microagents: [
{
id: "no-comments",
name: "No comments",
repositoryUrl: "fairwinds/polaris/Repo Overview",
createdAt: "05/30/2025",
},
],
},
];
export function MicroagentManagementRepoMicroagents({
repository,
}: MicroagentManagementRepoMicroagentsProps) {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const numberOfRepoMicroagents = repoMicroagents.length;
const dispatch = useDispatch();
const { full_name: repositoryName, git_provider: gitProvider } = repository;
// Extract owner and repo from repositoryName (format: "owner/repo")
const [owner, repo] = repositoryName.split("/");
const repositoryUrl = `${getGitProviderBaseUrl(gitProvider)}/${repositoryName}`;
const {
data: microagents,
isLoading: isLoadingMicroagents,
isError: isErrorMicroagents,
} = useRepositoryMicroagents(owner, repo);
const {
data: conversations,
isLoading: isLoadingConversations,
isError: isErrorConversations,
} = useSearchConversations(repositoryName, "microagent_management", 1000);
useEffect(() => {
const hasConversations = conversations && conversations.length > 0;
const selectedConversation = selectedMicroagentItem?.conversation;
if (hasConversations && selectedConversation) {
// get the latest selected conversation.
const latestSelectedConversation = conversations.find(
(conversation) =>
conversation.conversation_id === selectedConversation.conversation_id,
);
if (latestSelectedConversation) {
dispatch(
setSelectedMicroagentItem({
microagent: null,
conversation: latestSelectedConversation,
}),
);
}
}
}, [conversations]);
// Show loading only when both queries are loading
const isLoading = isLoadingMicroagents || isLoadingConversations;
// Show error UI.
const isError = isErrorMicroagents || isErrorConversations;
if (isLoading) {
return (
<div className="pb-4 flex justify-center">
<LoadingSpinner size="small" />
</div>
);
if (numberOfRepoMicroagents === 0) {
return null;
}
// If there's an error with microagents, show the learn this repo component
if (isError) {
return (
<div className="pb-4">
<MicroagentManagementLearnThisRepo repositoryUrl={repositoryUrl} />
</div>
);
}
const numberOfMicroagents = microagents?.length || 0;
const numberOfConversations = conversations?.length || 0;
const totalItems = numberOfMicroagents + numberOfConversations;
return (
<div className="pb-4">
{totalItems === 0 && (
<MicroagentManagementLearnThisRepo repositoryUrl={repositoryUrl} />
)}
{/* Render microagents */}
{numberOfMicroagents > 0 &&
microagents?.map((microagent) => (
<div key={microagent.name} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
microagent={microagent}
repository={repository}
/>
</div>
))}
{/* Render conversations */}
{numberOfConversations > 0 &&
conversations?.map((conversation) => (
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
conversation={conversation}
repository={repository}
/>
</div>
))}
<div>
{repoMicroagents.map((repoMicroagent) => (
<MicroagentManagementRepoMicroagent
key={repoMicroagent.id}
repoMicroagent={repoMicroagent}
/>
))}
</div>
);
}

View File

@@ -1,119 +0,0 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Accordion, AccordionItem } from "@heroui/react";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { GitRepository } from "#/types/git";
import { cn } from "#/utils/utils";
import { TabType } from "#/types/microagent-management";
import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories";
import { I18nKey } from "#/i18n/declaration";
import { DOCUMENTATION_URL } from "#/utils/constants";
import { MicroagentManagementAccordionTitle } from "./microagent-management-accordion-title";
import { sanitizeQuery } from "#/utils/sanitize-query";
type MicroagentManagementRepositoriesProps = {
repositories: GitRepository[];
tabType: TabType;
};
export function MicroagentManagementRepositories({
repositories,
tabType,
}: MicroagentManagementRepositoriesProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const numberOfRepoMicroagents = repositories.length;
// Filter repositories based on search query
const filteredRepositories = useMemo(() => {
if (!searchQuery.trim()) {
return repositories;
}
const sanitizedQuery = sanitizeQuery(searchQuery);
return repositories.filter((repository) => {
const sanitizedRepoName = sanitizeQuery(repository.full_name);
return sanitizedRepoName.includes(sanitizedQuery);
});
}, [repositories, searchQuery]);
if (numberOfRepoMicroagents === 0) {
if (tabType === "personal") {
return (
<MicroagentManagementNoRepositories
title={t(
I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS,
)}
documentationUrl={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
/>
);
}
if (tabType === "repositories") {
return (
<MicroagentManagementNoRepositories
title={t(I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS)}
documentationUrl={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
/>
);
}
if (tabType === "organizations") {
return (
<MicroagentManagementNoRepositories
title={t(
I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS,
)}
documentationUrl={
DOCUMENTATION_URL.MICROAGENTS.ORGANIZATION_AND_USER_MICROAGENTS
}
/>
);
}
}
return (
<div className="flex flex-col gap-4 w-full">
{/* Search Input */}
<div className="flex flex-col gap-2 w-full">
<label htmlFor="repository-search" className="sr-only">
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
</label>
<input
id="repository-search"
name="repository-search"
type="text"
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</div>
{/* Repositories Accordion */}
<Accordion
variant="splitted"
className="w-full px-0 gap-3"
itemClasses={{
base: "shadow-none bg-transparent border border-[#ffffff40] rounded-[6px] cursor-pointer",
trigger: "cursor-pointer",
}}
selectionMode="multiple"
>
{filteredRepositories.map((repository) => (
<AccordionItem
key={repository.id}
aria-label={repository.full_name}
title={
<MicroagentManagementAccordionTitle repository={repository} />
}
>
<MicroagentManagementRepoMicroagents repository={repository} />
</AccordionItem>
))}
</Accordion>
</div>
);
}

View File

@@ -1,74 +0,0 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { getProviderName, constructPullRequestUrl } from "#/utils/utils";
import { Provider } from "#/types/settings";
import { RootState } from "#/store";
export function MicroagentManagementReviewPr() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const {
conversation_id: conversationId,
selected_repository: selectedRepository,
git_provider: gitProvider,
pr_number: prNumber,
} = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY)}
</div>
<div className="flex gap-[22px]">
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
<a
href={
selectedRepository && gitProvider && prNumber && prNumber.length > 0
? constructPullRequestUrl(
prNumber[0],
gitProvider,
selectedRepository,
)
: "/#"
}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="primary"
testId="view-conversation-button"
>
{`${t(I18nKey.COMMON$REVIEW_PR_IN)} ${getProviderName(
gitProvider as Provider,
)}`}
</BrandButton>
</a>
</div>
</div>
);
}

View File

@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import QuestionCircleIcon from "#/icons/question-circle.svg?react";
import { DOCUMENTATION_URL } from "#/utils/constants";
export function MicroagentManagementSidebarHeader() {
const { t } = useTranslation();
@@ -13,13 +12,7 @@ export function MicroagentManagementSidebarHeader() {
</h1>
<p className="text-white text-sm font-normal leading-[20px] pt-2">
{t(I18nKey.MICROAGENT_MANAGEMENT$USE_MICROAGENTS)}
<a
href={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
target="_blank"
rel="noopener noreferrer"
>
<QuestionCircleIcon className="inline-block ml-1" />
</a>
<QuestionCircleIcon className="inline-block ml-1" />
</p>
</div>
);

View File

@@ -1,16 +1,12 @@
import { Tab, Tabs } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { MicroagentManagementRepositories } from "./microagent-management-repositories";
import { MicroagentManagementMicroagents } from "./microagent-management-microagents";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
export function MicroagentManagementSidebarTabs() {
const { t } = useTranslation();
const { repositories, personalRepositories, organizationRepositories } =
useSelector((state: RootState) => state.microagentManagement);
return (
<div className="flex w-full flex-col">
<Tabs
@@ -21,27 +17,18 @@ export function MicroagentManagementSidebarTabs() {
"w-full bg-transparent border border-[#ffffff40] rounded-[6px]",
tab: "px-2 h-[22px]",
tabContent: "text-white text-[12px] font-normal",
panel: "p-0",
panel: "py-0",
cursor: "bg-[#C9B97480] rounded-sm",
}}
>
<Tab key="personal" title={t(I18nKey.COMMON$PERSONAL)}>
<MicroagentManagementRepositories
repositories={personalRepositories}
tabType="personal"
/>
<MicroagentManagementMicroagents />
</Tab>
<Tab key="repositories" title={t(I18nKey.COMMON$REPOSITORIES)}>
<MicroagentManagementRepositories
repositories={repositories}
tabType="repositories"
/>
<MicroagentManagementRepoMicroagents />
</Tab>
<Tab key="organizations" title={t(I18nKey.COMMON$ORGANIZATIONS)}>
<MicroagentManagementRepositories
repositories={organizationRepositories}
tabType="organizations"
/>
<MicroagentManagementMicroagents />
</Tab>
</Tabs>
</div>

View File

@@ -1,71 +1,11 @@
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import {
setPersonalRepositories,
setOrganizationRepositories,
setRepositories,
} from "#/state/microagent-management-slice";
import { GitRepository } from "#/types/git";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { cn } from "#/utils/utils";
interface MicroagentManagementSidebarProps {
isSmallerScreen?: boolean;
}
export function MicroagentManagementSidebar({
isSmallerScreen = false,
}: MicroagentManagementSidebarProps) {
const dispatch = useDispatch();
const { t } = useTranslation();
const { data: repositories, isLoading } = useUserRepositories();
useEffect(() => {
if (repositories) {
const personalRepos: GitRepository[] = [];
const organizationRepos: GitRepository[] = [];
const otherRepos: GitRepository[] = [];
repositories.forEach((repo: GitRepository) => {
const hasOpenHandsSuffix = repo.full_name.endsWith("/.openhands");
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
personalRepos.push(repo);
} else if (repo.owner_type === "organization" && hasOpenHandsSuffix) {
organizationRepos.push(repo);
} else {
otherRepos.push(repo);
}
});
dispatch(setPersonalRepositories(personalRepos));
dispatch(setOrganizationRepositories(organizationRepos));
dispatch(setRepositories(otherRepos));
}
}, [repositories, dispatch]);
export function MicroagentManagementSidebar() {
return (
<div
className={cn(
"w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col",
isSmallerScreen && "w-full border-none",
)}
>
<div className="w-[418px] h-full border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6">
<MicroagentManagementSidebarHeader />
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 flex-1">
<LoadingSpinner size="small" />
<span className="text-sm text-white">
{t("HOME$LOADING_REPOSITORIES")}
</span>
</div>
) : (
<MicroagentManagementSidebarTabs />
)}
<MicroagentManagementSidebarTabs />
</div>
);
}

View File

@@ -1,73 +0,0 @@
import { useSelector } from "react-redux";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { code } from "../markdown/code";
import { ul, ol } from "../markdown/list";
import { paragraph } from "../markdown/paragraph";
import { anchor } from "../markdown/anchor";
import { RootState } from "#/store";
export function MicroagentManagementViewMicroagentContent() {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
const transformMicroagentContent = (): string => {
if (!microagent) {
return "";
}
// If no triggers exist, return the content as-is
if (!microagent.triggers || microagent.triggers.length === 0) {
return microagent.content;
}
// Create the triggers frontmatter
const triggersFrontmatter = `
---
triggers:
${microagent.triggers.map((trigger) => ` - ${trigger}`).join("\n")}
---
`;
// Prepend the frontmatter to the content
return `
${triggersFrontmatter}
${microagent.content}
`;
};
if (!microagent || !selectedRepository) {
return null;
}
// Transform the content to include triggers frontmatter if applicable
const transformedContent = transformMicroagentContent();
return (
<div className="w-full h-full p-6 bg-[#ffffff1a] rounded-2xl text-white text-sm">
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{transformedContent}
</Markdown>
</div>
);
}

View File

@@ -1,60 +0,0 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RootState } from "#/store";
import { BrandButton } from "../settings/brand-button";
import { getProviderName, constructMicroagentUrl } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementViewMicroagentHeader() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
if (!microagent || !selectedRepository) {
return null;
}
// Construct the microagent URL
const microagentUrl = constructMicroagentUrl(
selectedRepository.git_provider,
selectedRepository.full_name,
microagent.path,
);
return (
<div className="flex items-center justify-between pb-2">
<span className="text-sm text-[#ffffff99]">
{selectedRepository.full_name}
</span>
<div className="flex items-center justify-end gap-2">
<a href={microagentUrl} target="_blank" rel="noopener noreferrer">
<BrandButton
type="button"
variant="secondary"
testId="edit-in-git-button"
className="py-1 px-2"
>
{`${t(I18nKey.COMMON$EDIT_IN)} ${getProviderName(selectedRepository.git_provider)}`}
</BrandButton>
</a>
<BrandButton
type="button"
variant="primary"
onClick={() => {}}
testId="learn-button"
className="py-1 px-2"
>
{t(I18nKey.COMMON$LEARN)}
</BrandButton>
</div>
</div>
);
}

View File

@@ -1,35 +0,0 @@
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { MicroagentManagementViewMicroagentHeader } from "./microagent-management-view-microagent-header";
import { MicroagentManagementViewMicroagentContent } from "./microagent-management-view-microagent-content";
export function MicroagentManagementViewMicroagent() {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
if (!microagent || !selectedRepository) {
return null;
}
return (
<div className="flex flex-col w-full h-full p-6 overflow-auto">
<MicroagentManagementViewMicroagentHeader />
<span className="text-white text-2xl font-medium pb-2">
{microagent.name}
</span>
<span className="text-white text-lg font-medium pb-6">
{microagent.path}
</span>
<div className="flex-1">
<MicroagentManagementViewMicroagentContent />
</div>
</div>
);
}

View File

@@ -2,7 +2,6 @@ import { useTranslation } from "react-i18next";
import SettingsIcon from "#/icons/settings.svg?react";
import { TooltipButton } from "./tooltip-button";
import { I18nKey } from "#/i18n/declaration";
import { useConfig } from "#/hooks/query/use-config";
interface SettingsButtonProps {
onClick?: () => void;
@@ -14,12 +13,6 @@ export function SettingsButton({
disabled = false,
}: SettingsButtonProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
// Determine the correct settings path based on app mode
// In SaaS mode, navigate directly to user settings to avoid the LLM settings page
const settingsPath =
config?.APP_MODE === "saas" ? "/settings/user" : "/settings";
return (
<TooltipButton
@@ -27,7 +20,7 @@ export function SettingsButton({
tooltip={t(I18nKey.SETTINGS$TITLE)}
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
onClick={onClick}
navLinkTo={settingsPath}
navLinkTo="/settings"
disabled={disabled}
>
<SettingsIcon width={28} height={28} />

View File

@@ -1,16 +0,0 @@
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
import { Provider } from "#/types/settings";
interface GitProviderIconProps {
gitProvider: Provider;
}
export function GitProviderIcon({ gitProvider }: GitProviderIconProps) {
return (
<>
{gitProvider === "github" && <FaGithub size={14} />}
{gitProvider === "gitlab" && <FaGitlab />}
{gitProvider === "bitbucket" && <FaBitbucket />}
</>
);
}

View File

@@ -1,25 +0,0 @@
import { cn } from "#/utils/utils";
interface LoaderProps {
size?: "small" | "medium" | "large";
className?: string;
}
export function Loader({ size = "medium", className }: LoaderProps) {
const sizeClasses = {
small: "w-3 h-3",
medium: "w-4 h-4",
large: "w-5 h-5",
};
const dotSize = sizeClasses[size];
return (
<div
data-testid="loader"
className={cn("flex items-center justify-center", className)}
>
<div className={cn("loader rounded-full", dotSize)} />
</div>
);
}

View File

@@ -3,7 +3,6 @@ import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { Provider } from "#/types/settings";
import { CreateMicroagent } from "#/api/open-hands.types";
interface CreateConversationVariables {
query?: string;
@@ -14,7 +13,6 @@ interface CreateConversationVariables {
};
suggestedTask?: SuggestedTask;
conversationInstructions?: string;
createMicroagent?: CreateMicroagent;
}
export const useCreateConversation = () => {
@@ -23,13 +21,8 @@ export const useCreateConversation = () => {
return useMutation({
mutationKey: ["create-conversation"],
mutationFn: async (variables: CreateConversationVariables) => {
const {
query,
repository,
suggestedTask,
conversationInstructions,
createMicroagent,
} = variables;
const { query, repository, suggestedTask, conversationInstructions } =
variables;
return OpenHands.createConversation(
repository?.name,
@@ -38,7 +31,6 @@ export const useCreateConversation = () => {
suggestedTask,
repository?.branch,
conversationInstructions,
createMicroagent,
);
},
onSuccess: async (_, { query, repository }) => {

View File

@@ -0,0 +1,23 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useIsAuthed } from "./use-is-authed";
import OpenHands from "#/api/open-hands";
import { useUserProviders } from "../use-user-providers";
export const useAppInstallations = () => {
const { data: config } = useConfig();
const { data: userIsAuthenticated } = useIsAuthed();
const { providers } = useUserProviders();
return useQuery({
queryKey: ["installations", providers, config?.GITHUB_CLIENT_ID],
queryFn: OpenHands.getGitHubUserInstallationIds,
enabled:
userIsAuthenticated &&
providers.includes("github") &&
!!config?.GITHUB_CLIENT_ID &&
config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};

View File

@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useIsAuthed } from "./use-is-authed";
import OpenHands from "#/api/open-hands";
import { useUserProviders } from "../use-user-providers";
export const useBitbucketWorkspaces = () => {
const { data: config } = useConfig();
const { data: userIsAuthenticated } = useIsAuthed();
const { providers } = useUserProviders();
return useQuery({
queryKey: ["workspaces", providers],
queryFn: OpenHands.getBitBucketWorkspaces,
enabled:
userIsAuthenticated &&
providers.includes("bitbucket") &&
config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};

View File

@@ -1,11 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useRepositoryMicroagents = (owner: string, repo: string) =>
useQuery({
queryKey: ["repository", "microagents", owner, repo],
queryFn: () => OpenHands.getRepositoryMicroagents(owner, repo),
enabled: !!owner && !!repo,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@@ -1,26 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useSearchConversations = (
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 20,
) =>
useQuery({
queryKey: [
"conversations",
"search",
selectedRepository,
conversationTrigger,
limit,
],
queryFn: () =>
OpenHands.searchConversations(
selectedRepository,
conversationTrigger,
limit,
),
enabled: true, // Always enabled since parameters are optional
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@@ -3,7 +3,6 @@ import { useCreateConversation } from "./mutation/use-create-conversation";
import { useUserProviders } from "./use-user-providers";
import { useConversationSubscriptions } from "#/context/conversation-subscriptions-provider";
import { Provider } from "#/types/settings";
import { CreateMicroagent } from "#/api/open-hands.types";
/**
* Custom hook to create a conversation and subscribe to it, supporting multiple subscriptions.
@@ -25,7 +24,6 @@ export const useCreateConversationAndSubscribeMultiple = () => {
query,
conversationInstructions,
repository,
createMicroagent,
onSuccessCallback,
onEventCallback,
}: {
@@ -36,7 +34,6 @@ export const useCreateConversationAndSubscribeMultiple = () => {
branch: string;
gitProvider: Provider;
};
createMicroagent?: CreateMicroagent;
onSuccessCallback?: (conversationId: string) => void;
onEventCallback?: (event: unknown, conversationId: string) => void;
}) => {
@@ -45,7 +42,6 @@ export const useCreateConversationAndSubscribeMultiple = () => {
query,
conversationInstructions,
repository,
createMicroagent,
},
{
onSuccess: (data) => {

View File

@@ -12,7 +12,6 @@ export enum I18nKey {
MICROAGENT$VIEW_CONVERSATION = "MICROAGENT$VIEW_CONVERSATION",
MICROAGENT$SUCCESS_PR_READY = "MICROAGENT$SUCCESS_PR_READY",
MICROAGENT$STATUS_CREATING = "MICROAGENT$STATUS_CREATING",
MICROAGENT$STATUS_OPENING_PR = "MICROAGENT$STATUS_OPENING_PR",
MICROAGENT$STATUS_COMPLETED = "MICROAGENT$STATUS_COMPLETED",
MICROAGENT$STATUS_ERROR = "MICROAGENT$STATUS_ERROR",
MICROAGENT$VIEW_YOUR_PR = "MICROAGENT$VIEW_YOUR_PR",
@@ -709,21 +708,4 @@ export enum I18nKey {
COMMON$RUN_TEST = "COMMON$RUN_TEST",
COMMON$RUN_APP = "COMMON$RUN_APP",
COMMON$LEARN_FILE_STRUCTURE = "COMMON$LEARN_FILE_STRUCTURE",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS",
COMMON$SEARCH_REPOSITORIES = "COMMON$SEARCH_REPOSITORIES",
COMMON$READY_FOR_REVIEW = "COMMON$READY_FOR_REVIEW",
COMMON$COMPLETED = "COMMON$COMPLETED",
COMMON$COMPLETED_PARTIALLY = "COMMON$COMPLETED_PARTIALLY",
COMMON$STOPPED = "COMMON$STOPPED",
COMMON$WORKING_ON_IT = "COMMON$WORKING_ON_IT",
MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT = "MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT",
MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY = "MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY",
COMMON$REVIEW_PR_IN = "COMMON$REVIEW_PR_IN",
COMMON$EDIT_IN = "COMMON$EDIT_IN",
COMMON$LEARN = "COMMON$LEARN",
COMMON$STARTING = "COMMON$STARTING",
MICROAGENT_MANAGEMENT$ERROR = "MICROAGENT_MANAGEMENT$ERROR",
MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED = "MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED",
}

View File

@@ -191,22 +191,6 @@
"de": "Microagent wird geändert...",
"uk": "Зміна мікроагента..."
},
"MICROAGENT$STATUS_OPENING_PR": {
"en": "Opening PR",
"ja": "PRを開いています",
"zh-CN": "正在打开PR",
"zh-TW": "正在打開PR",
"ko-KR": "PR 열는 중",
"no": "Åpner PR",
"it": "Apertura PR",
"pt": "Abrindo PR",
"es": "Abriendo PR",
"ar": "فتح PR",
"fr": "Ouverture de la PR",
"tr": "PR açılıyor",
"de": "PR wird geöffnet",
"uk": "Відкриття PR"
},
"MICROAGENT$STATUS_COMPLETED": {
"en": "View microagent update",
"ja": "マイクロエージェントの更新を表示",
@@ -11213,7 +11197,7 @@
"fr": "Que souhaitez-vous que le microagent fasse ?",
"tr": "Mikro ajanın ne yapmasını istersiniz?",
"de": "Was soll der Microagent tun?",
"uk": "Що в,и хочете, щоб зробив мікроагент?"
"uk": "Що ви хочете, щоб зробив мікроагент?"
},
"MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO": {
"en": "Describe what you would like the Microagent to do.",
@@ -11342,277 +11326,5 @@
"tr": "Dosya yapısını öğren",
"de": "Dateistruktur lernen",
"uk": "Вивчити структуру файлів"
},
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS": {
"en": "You do not have user-level microagents",
"ja": "ユーザーレベルのマイクロエージェントがありません",
"zh-CN": "您没有用户级微代理",
"zh-TW": "您沒有使用者層級的微代理",
"ko-KR": "사용자 수준의 마이크로에이전트가 없습니다",
"no": "Du har ikke mikroagenter på brukernivå",
"it": "Non hai microagenti a livello utente",
"pt": "Você não possui microagentes de nível de usuário",
"es": "No tienes microagentes a nivel de usuario",
"ar": "ليس لديك وكلاء دقيقون على مستوى المستخدم",
"fr": "Vous n'avez pas de microagents au niveau utilisateur",
"tr": "Kullanıcı düzeyinde mikro ajanınız yok",
"de": "Sie haben keine Mikroagenten auf Benutzerebene",
"uk": "У вас немає мікроагентів на рівні користувача"
},
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS": {
"en": "You do not have microagents",
"ja": "マイクロエージェントがありません",
"zh-CN": "您没有微代理",
"zh-TW": "您沒有微代理",
"ko-KR": "마이크로에이전트가 없습니다",
"no": "Du har ingen mikroagenter",
"it": "Non hai microagenti",
"pt": "Você não possui microagentes",
"es": "No tienes microagentes",
"ar": "ليس لديك وكلاء دقيقون",
"fr": "Vous n'avez pas de microagents",
"tr": "Mikro ajanınız yok",
"de": "Sie haben keine Mikroagenten",
"uk": "У вас немає мікроагентів"
},
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS": {
"en": "You do not have organization-level microagents",
"ja": "組織レベルのマイクロエージェントがありません",
"zh-CN": "您没有组织级微代理",
"zh-TW": "您沒有組織層級的微代理",
"ko-KR": "조직 수준의 마이크로에이전트가 없습니다",
"no": "Du har ikke mikroagenter på organisasjonsnivå",
"it": "Non hai microagenti a livello organizzazione",
"pt": "Você não possui microagentes de nível organizacional",
"es": "No tienes microagentes a nivel de organización",
"ar": "ليس لديك وكلاء دقيقون على مستوى المؤسسة",
"fr": "Vous n'avez pas de microagents au niveau organisation",
"tr": "Organizasyon düzeyinde mikro ajanınız yok",
"de": "Sie haben keine Mikroagenten auf Organisationsebene",
"uk": "У вас немає мікроагентів на рівні організації"
},
"COMMON$SEARCH_REPOSITORIES": {
"en": "Search repositories",
"ja": "リポジトリを検索",
"zh-CN": "搜索仓库",
"zh-TW": "搜尋存儲庫",
"ko-KR": "저장소 검색",
"no": "Søk i repositories",
"it": "Cerca repository",
"pt": "Pesquisar repositórios",
"es": "Buscar repositorios",
"ar": "البحث في المستودعات",
"fr": "Rechercher des dépôts",
"tr": "Depo ara",
"de": "Repositorys durchsuchen",
"uk": "Пошук репозиторіїв"
},
"COMMON$READY_FOR_REVIEW": {
"en": "Ready for review",
"ja": "レビューの準備ができました",
"zh-CN": "准备好审核",
"zh-TW": "已準備好審查",
"ko-KR": "검토 준비 완료",
"no": "Klar for gjennomgang",
"it": "Pronto per la revisione",
"pt": "Pronto para revisão",
"es": "Listo para revisión",
"ar": "جاهز للمراجعة",
"fr": "Prêt pour la relecture",
"tr": "İncelemeye hazır",
"de": "Bereit zur Überprüfung",
"uk": "Готово до перегляду"
},
"COMMON$COMPLETED": {
"en": "Completed",
"ja": "完了",
"zh-CN": "已完成",
"zh-TW": "已完成",
"ko-KR": "완료됨",
"no": "Fullført",
"it": "Completato",
"pt": "Concluído",
"es": "Completado",
"ar": "مكتمل",
"fr": "Terminé",
"tr": "Tamamlandı",
"de": "Abgeschlossen",
"uk": "Завершено"
},
"COMMON$COMPLETED_PARTIALLY": {
"en": "Completed partially",
"ja": "一部完了",
"zh-CN": "部分完成",
"zh-TW": "部分完成",
"ko-KR": "부분적으로 완료됨",
"no": "Delvis fullført",
"it": "Completato parzialmente",
"pt": "Concluído parcialmente",
"es": "Completado parcialmente",
"ar": "مكتمل جزئيًا",
"fr": "Partiellement terminé",
"tr": "Kısmen tamamlandı",
"de": "Teilweise abgeschlossen",
"uk": "Частково завершено"
},
"COMMON$STOPPED": {
"en": "Stopped",
"ja": "停止しました",
"zh-CN": "已停止",
"zh-TW": "已停止",
"ko-KR": "중지됨",
"no": "Stoppet",
"it": "Interrotto",
"pt": "Parado",
"es": "Detenido",
"ar": "متوقف",
"fr": "Arrêté",
"tr": "Durduruldu",
"de": "Gestoppt",
"uk": "Зупинено"
},
"COMMON$WORKING_ON_IT": {
"en": "Working on it",
"ja": "作業中",
"zh-CN": "正在处理",
"zh-TW": "正在處理",
"ko-KR": "작업 중",
"no": "Jobber med det",
"it": "Ci sto lavorando",
"pt": "Trabalhando nisso",
"es": "Trabajando en ello",
"ar": "يتم العمل عليه",
"fr": "En cours",
"tr": "Üzerinde çalışılıyor",
"de": "Wird bearbeitet",
"uk": "В процесі виконання"
},
"MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT": {
"en": "We're working on it! Once OpenHands is done investigating, you'll be able to review its pull request before merging your new microagent.",
"ja": "作業中ですOpenHandsの調査が完了すると、新しいマイクロエージェントをマージする前にプルリクエストを確認できます。",
"zh-CN": "我们正在处理OpenHands 调查完成后,您将能够在合并新微代理之前审查其拉取请求。",
"zh-TW": "我們正在處理OpenHands 調查完成後,您將能在合併新微代理前審查其拉取請求。",
"ko-KR": "작업 중입니다! OpenHands의 조사가 끝나면 새 마이크로에이전트를 병합하기 전에 풀 리퀘스트를 검토할 수 있습니다.",
"no": "Vi jobber med det! Når OpenHands er ferdig med å undersøke, kan du gjennomgå pull requesten før du slår sammen din nye mikroagent.",
"it": "Ci stiamo lavorando! Una volta che OpenHands avrà terminato l'analisi, potrai rivedere la pull request prima di unire il tuo nuovo microagent.",
"pt": "Estamos trabalhando nisso! Assim que o OpenHands terminar a investigação, você poderá revisar o pull request antes de mesclar seu novo microagente.",
"es": "¡Estamos trabajando en ello! Una vez que OpenHands termine de investigar, podrás revisar su pull request antes de fusionar tu nuevo microagente.",
"ar": "نحن نعمل على ذلك! بمجرد أن ينتهي OpenHands من التحقيق، ستتمكن من مراجعة طلب السحب قبل دمج وكيلك الدقيق الجديد.",
"fr": "Nous y travaillons ! Une fois qu'OpenHands aura terminé l'investigation, vous pourrez examiner sa pull request avant de fusionner votre nouveau microagent.",
"tr": "Üzerinde çalışıyoruz! OpenHands incelemeyi bitirdiğinde, yeni mikro ajanınızı birleştirmeden önce pull request'i gözden geçirebileceksiniz.",
"de": "Wir arbeiten daran! Sobald OpenHands die Untersuchung abgeschlossen hat, können Sie den Pull Request überprüfen, bevor Sie Ihren neuen Microagenten zusammenführen.",
"uk": "Ми працюємо над цим! Після завершення розслідування OpenHands ви зможете переглянути його pull request перед об'єднанням нового мікроагента."
},
"MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY": {
"en": "Your microagent is ready! Merge the PR in GitHub to start using it.",
"ja": "マイクロエージェントの準備ができましたGitHubでPRをマージして使い始めましょう。",
"zh-CN": "您的微代理已准备就绪!在 GitHub 上合并 PR 即可开始使用。",
"zh-TW": "您的微代理已準備就緒!在 GitHub 上合併 PR 即可開始使用。",
"ko-KR": "마이크로에이전트가 준비되었습니다! GitHub에서 PR을 병합하여 사용을 시작하세요.",
"no": "Din mikroagent er klar! Slå sammen PR-en i GitHub for å begynne å bruke den.",
"it": "Il tuo microagente è pronto! Unisci la PR su GitHub per iniziare a usarlo.",
"pt": "Seu microagente está pronto! Faça o merge do PR no GitHub para começar a usá-lo.",
"es": "¡Tu microagente está listo! Haz merge del PR en GitHub para empezar a usarlo.",
"ar": "وكيلك المصغر جاهز! ادمج طلب السحب في GitHub لبدء استخدامه.",
"fr": "Votre micro-agent est prêt ! Fusionnez la PR sur GitHub pour commencer à l'utiliser.",
"tr": "Mikro ajanınız hazır! Kullanmak için GitHub'da PR'ı birleştirin.",
"de": "Ihr Microagent ist bereit! Führen Sie den PR in GitHub zusammen, um ihn zu verwenden.",
"uk": "Ваш мікроагент готовий! Злийте PR у GitHub, щоб почати ним користуватися."
},
"COMMON$REVIEW_PR_IN": {
"en": "Review PR in",
"ja": "でPRをレビュー",
"zh-CN": "在中审查PR",
"zh-TW": "在中審查PR",
"ko-KR": "에서 PR 검토",
"no": "Se gjennom PR i",
"it": "Revisiona la PR su",
"pt": "Revisar PR em",
"es": "Revisar PR en",
"ar": "مراجعة PR في",
"fr": "Examiner la PR sur",
"tr": "PR'ı şurada gözden geçir:",
"de": "PR überprüfen in",
"uk": "Переглянути PR у"
},
"COMMON$EDIT_IN": {
"en": "Edit in",
"ja": "で編集",
"zh-CN": "在中编辑",
"zh-TW": "在中編輯",
"ko-KR": "에서 편집",
"no": "Rediger i",
"it": "Modifica su",
"pt": "Editar em",
"es": "Editar en",
"ar": "تعديل في",
"fr": "Modifier dans",
"tr": "Şurada düzenle:",
"de": "Bearbeiten in",
"uk": "Редагувати у"
},
"COMMON$LEARN": {
"en": "Learn",
"ja": "学ぶ",
"zh-CN": "学习",
"zh-TW": "學習",
"ko-KR": "학습",
"no": "Lær",
"it": "Impara",
"pt": "Aprender",
"es": "Aprender",
"ar": "تعلم",
"fr": "Apprendre",
"tr": "Öğren",
"de": "Lernen",
"uk": "Вчитися"
},
"COMMON$STARTING": {
"en": "Starting",
"ja": "開始中",
"zh-CN": "启动中",
"zh-TW": "啟動中",
"ko-KR": "시작 중",
"no": "Starter",
"it": "Avvio",
"pt": "Iniciando",
"es": "Iniciando",
"ar": "جارٍ البدء",
"fr": "Démarrage",
"tr": "Başlatılıyor",
"de": "Wird gestartet",
"uk": "Запуск"
},
"MICROAGENT_MANAGEMENT$ERROR": {
"en": "The system has encountered an error. Please try again later.",
"ja": "システムでエラーが発生しました。後でもう一度お試しください。",
"zh-CN": "系统遇到错误。请稍后再试。",
"zh-TW": "系統發生錯誤。請稍後再試。",
"ko-KR": "시스템에 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
"no": "Systemet har oppdaget en feil. Prøv igjen senere.",
"it": "Il sistema ha riscontrato un errore. Riprova più tardi.",
"pt": "O sistema encontrou um erro. Por favor, tente novamente mais tarde.",
"es": "El sistema ha encontrado un error. Por favor, inténtalo de nuevo más tarde.",
"ar": "واجه النظام خطأ. يرجى المحاولة مرة أخرى لاحقًا.",
"fr": "Le système a rencontré une erreur. Veuillez réessayer plus tard.",
"tr": "Sistem bir hata ile karşılaştı. Lütfen daha sonra tekrar deneyin.",
"de": "Das System hat einen Fehler festgestellt. Bitte versuchen Sie es später erneut.",
"uk": "Система зіткнулася з помилкою. Будь ласка, спробуйте пізніше."
},
"MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED": {
"en": "The conversation has been stopped.",
"ja": "会話が停止されました。",
"zh-CN": "对话已被停止。",
"zh-TW": "對話已被停止。",
"ko-KR": "대화가 중단되었습니다.",
"no": "Samtalen har blitt stoppet.",
"it": "La conversazione è stata interrotta.",
"pt": "A conversa foi interrompida.",
"es": "La conversación ha sido detenida.",
"ar": "تم إيقاف المحادثة.",
"fr": "La conversation a été arrêtée.",
"tr": "Konuşma durduruldu.",
"de": "Das Gespräch wurde gestoppt.",
"uk": "Розмову зупинено."
}
}

View File

@@ -1,11 +1,14 @@
import { redirect } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementSidebar } from "#/components/features/microagent-management/microagent-management-sidebar";
import { Route } from "./+types/settings";
import { queryClient } from "#/query-client-config";
import { GetConfigResponse } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
import { MicroagentManagementContent } from "#/components/features/microagent-management/microagent-management-content";
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
import { EventHandler } from "#/wrapper/event-handler";
import { MicroagentManagementMain } from "#/components/features/microagent-management/microagent-management-main";
import { MicroagentManagementAddMicroagentModal } from "#/components/features/microagent-management/microagent-management-add-microagent-modal";
import { RootState } from "#/store";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
const url = new URL(request.url);
@@ -28,12 +31,31 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
};
function MicroagentManagement() {
const { addMicroagentModalVisible } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const hideAddMicroagentModal = () => {
dispatch(setAddMicroagentModalVisible(false));
};
return (
<ConversationSubscriptionsProvider>
<EventHandler>
<MicroagentManagementContent />
</EventHandler>
</ConversationSubscriptionsProvider>
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E]">
<MicroagentManagementSidebar />
<MicroagentManagementMain />
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={() => {
hideAddMicroagentModal();
}}
onCancel={() => {
hideAddMicroagentModal();
}}
/>
)}
</div>
);
}

View File

@@ -1,46 +1,29 @@
import { createSlice } from "@reduxjs/toolkit";
import { GitRepository } from "#/types/git";
import { IMicroagentItem } from "#/types/microagent-management";
export const microagentManagementSlice = createSlice({
name: "microagentManagement",
initialState: {
selectedMicroagent: null,
addMicroagentModalVisible: false,
selectedRepository: null as GitRepository | null,
personalRepositories: [] as GitRepository[],
organizationRepositories: [] as GitRepository[],
repositories: [] as GitRepository[],
selectedMicroagentItem: null as IMicroagentItem | null,
selectedRepository: null,
},
reducers: {
setSelectedMicroagent: (state, action) => {
state.selectedMicroagent = action.payload;
},
setAddMicroagentModalVisible: (state, action) => {
state.addMicroagentModalVisible = action.payload;
},
setSelectedRepository: (state, action) => {
state.selectedRepository = action.payload;
},
setPersonalRepositories: (state, action) => {
state.personalRepositories = action.payload;
},
setOrganizationRepositories: (state, action) => {
state.organizationRepositories = action.payload;
},
setRepositories: (state, action) => {
state.repositories = action.payload;
},
setSelectedMicroagentItem: (state, action) => {
state.selectedMicroagentItem = action.payload;
},
},
});
export const {
setSelectedMicroagent,
setAddMicroagentModalVisible,
setSelectedRepository,
setPersonalRepositories,
setOrganizationRepositories,
setRepositories,
setSelectedMicroagentItem,
} = microagentManagementSlice.actions;
export default microagentManagementSlice.reducer;

View File

@@ -20,27 +20,3 @@
.heading {
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
}
.loader {
background: #C9B974;
animation: l5 1s infinite linear alternate;
}
@keyframes l5 {
0% {
box-shadow: 20px 0 #C9B974, -20px 0 rgba(201,185,116,0.1);
background: #C9B974;
}
33% {
box-shadow: 20px 0 #C9B974, -20px 0 rgba(201,185,116,0.1);
background: rgba(201,185,116,0.1);
}
66% {
box-shadow: 20px 0 rgba(201,185,116,0.1), -20px 0 #C9B974;
background: rgba(201,185,116,0.1);
}
100% {
box-shadow: 20px 0 rgba(201,185,116,0.1), -20px 0 #C9B974;
background: #C9B974;
}
}

View File

@@ -30,7 +30,6 @@ interface GitRepository {
stargazers_count?: number;
link_header?: string;
pushed_at?: string;
owner_type?: "user" | "organization";
}
interface GitHubCommit {

View File

@@ -1,26 +0,0 @@
import { Conversation } from "#/api/open-hands.types";
export type TabType = "personal" | "repositories" | "organizations";
export interface RepositoryMicroagent {
name: string;
type: "repo" | "knowledge";
content: string;
triggers: string[];
inputs: string[];
tools: string[];
created_at: string;
git_provider: string;
path: string;
}
export interface IMicroagentItem {
microagent?: RepositoryMicroagent;
conversation?: Conversation;
}
export interface MicroagentFormData {
query: string;
triggers: string[];
selectedBranch: string;
}

View File

@@ -28,12 +28,3 @@ export const JSON_VIEW_THEME = {
base0E: "#c792ea", // keywords, purple
base0F: "#ff5370", // deprecated, red
};
export const DOCUMENTATION_URL = {
MICROAGENTS: {
MICROAGENTS_OVERVIEW:
"https://docs.all-hands.dev/usage/prompting/microagents-overview",
ORGANIZATION_AND_USER_MICROAGENTS:
"https://docs.all-hands.dev/usage/prompting/microagents-org",
},
};

View File

@@ -26,19 +26,3 @@ export const formatTimeDelta = (date: Date) => {
if (months < 12) return `${months}mo`;
return `${years}y`;
};
/**
* Formats a date into a MM/DD/YYYY string format.
* @param date The date to format
* @returns A string in MM/DD/YYYY format
*
* @example
* formatDateMMDDYYYY(new Date("2025-05-30T00:15:08")); // "05/30/2025"
* formatDateMMDDYYYY(new Date("2024-12-25T10:30:00")); // "12/25/2024"
*/
export const formatDateMMDDYYYY = (date: Date) =>
date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
});

View File

@@ -1,6 +1,5 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Provider } from "#/types/settings";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -103,107 +102,3 @@ export const formatTimestamp = (timestamp: string) =>
minute: "2-digit",
second: "2-digit",
});
export const getGitProviderBaseUrl = (gitProvider: Provider): string => {
switch (gitProvider) {
case "github":
return "https://github.com";
case "gitlab":
return "https://gitlab.com";
case "bitbucket":
return "https://bitbucket.org";
default:
return "";
}
};
/**
* Get the name of the git provider
* @param gitProvider The git provider
* @returns The name of the git provider
*/
export const getProviderName = (gitProvider: Provider) => {
if (gitProvider === "gitlab") return "GitLab";
if (gitProvider === "bitbucket") return "Bitbucket";
return "GitHub";
};
/**
* Get the name of the PR
* @param isGitLab Whether the git provider is GitLab
* @returns The name of the PR
*/
export const getPR = (isGitLab: boolean) =>
isGitLab ? "merge request" : "pull request";
/**
* Get the short name of the PR
* @param isGitLab Whether the git provider is GitLab
* @returns The short name of the PR
*/
export const getPRShort = (isGitLab: boolean) => (isGitLab ? "MR" : "PR");
/**
* Construct the pull request (merge request) URL for different providers
* @param prNumber The pull request number
* @param provider The git provider
* @param repositoryName The repository name in format "owner/repo"
* @returns The pull request URL
*
* @example
* constructPullRequestUrl(123, "github", "owner/repo") // "https://github.com/owner/repo/pull/123"
* constructPullRequestUrl(456, "gitlab", "owner/repo") // "https://gitlab.com/owner/repo/-/merge_requests/456"
* constructPullRequestUrl(789, "bitbucket", "owner/repo") // "https://bitbucket.org/owner/repo/pull-requests/789"
*/
export const constructPullRequestUrl = (
prNumber: number,
provider: Provider,
repositoryName: string,
): string => {
const baseUrl = getGitProviderBaseUrl(provider);
switch (provider) {
case "github":
return `${baseUrl}/${repositoryName}/pull/${prNumber}`;
case "gitlab":
return `${baseUrl}/${repositoryName}/-/merge_requests/${prNumber}`;
case "bitbucket":
return `${baseUrl}/${repositoryName}/pull-requests/${prNumber}`;
default:
return "";
}
};
/**
* Construct the microagent URL for different providers
* @param gitProvider The git provider
* @param repositoryName The repository name in format "owner/repo"
* @param microagentPath The path to the microagent in the repository
* @returns The URL to the microagent file in the Git provider
*
* @example
* constructMicroagentUrl("github", "owner/repo", ".openhands/microagents/tell-me-a-joke.md")
* // "https://github.com/owner/repo/blob/main/.openhands/microagents/tell-me-a-joke.md"
* constructMicroagentUrl("gitlab", "owner/repo", "microagents/git-helper.md")
* // "https://gitlab.com/owner/repo/-/blob/main/microagents/git-helper.md"
* constructMicroagentUrl("bitbucket", "owner/repo", ".openhands/microagents/docker-helper.md")
* // "https://bitbucket.org/owner/repo/src/main/.openhands/microagents/docker-helper.md"
*/
export const constructMicroagentUrl = (
gitProvider: Provider,
repositoryName: string,
microagentPath: string,
): string => {
const baseUrl = getGitProviderBaseUrl(gitProvider);
switch (gitProvider) {
case "github":
return `${baseUrl}/${repositoryName}/blob/main/${microagentPath}`;
case "gitlab":
return `${baseUrl}/${repositoryName}/-/blob/main/${microagentPath}`;
case "bitbucket":
return `${baseUrl}/${repositoryName}/src/main/${microagentPath}`;
default:
return "";
}
};

View File

@@ -17,9 +17,7 @@ from openhands.cli.settings import modify_llm_settings_basic
from openhands.cli.shell_config import (
ShellConfigManager,
add_aliases_to_shell_config,
alias_setup_declined,
aliases_exist_in_shell_config,
mark_alias_setup_declined,
)
from openhands.cli.tui import (
UsageMetrics,
@@ -389,86 +387,106 @@ def run_alias_setup_flow(config: OpenHandsConfig) -> None:
Prompts the user to set up aliases for 'openhands' and 'oh' commands.
Handles existing aliases by offering to keep or remove them.
Args:
config: OpenHands configuration
"""
print_formatted_text('')
print_formatted_text(HTML('<gold>🚀 Welcome to OpenHands CLI!</gold>'))
print_formatted_text('')
# Show the normal setup flow
print_formatted_text(
HTML('<grey>Would you like to set up convenient shell aliases?</grey>')
)
print_formatted_text('')
print_formatted_text(
HTML('<grey>This will add the following aliases to your shell profile:</grey>')
)
print_formatted_text(
HTML(
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text('')
print_formatted_text(
HTML(
'<ansiyellow>⚠️ Note: This requires uv to be installed first.</ansiyellow>'
)
)
print_formatted_text(
HTML(
'<ansiyellow> Installation guide: https://docs.astral.sh/uv/getting-started/installation</ansiyellow>'
)
)
print_formatted_text('')
# Use cli_confirm to get user choice
choice = cli_confirm(
config,
'Set up shell aliases?',
['Yes, set up aliases', 'No, skip this step'],
)
if choice == 0: # User chose "Yes"
success = add_aliases_to_shell_config()
if success:
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Aliases added successfully!</ansigreen>')
# Check if aliases already exist
if aliases_exist_in_shell_config():
print_formatted_text(
HTML(
'<grey>We detected existing OpenHands aliases in your shell configuration.</grey>'
)
# Get the appropriate reload command using the shell config manager
shell_manager = ShellConfigManager()
reload_cmd = shell_manager.get_reload_command()
print_formatted_text(
HTML(
f'<grey>Run <b>{reload_cmd}</b> (or restart your terminal) to use the new aliases.</grey>'
)
)
else:
print_formatted_text('')
print_formatted_text(
HTML(
'<ansired>❌ Failed to add aliases. You can set them up manually later.</ansired>'
)
)
else: # User chose "No"
# Mark that the user has declined alias setup
mark_alias_setup_declined()
)
print_formatted_text('')
print_formatted_text(
HTML(
'<grey>Skipped alias setup. You can run this setup again anytime.</grey>'
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Aliases are already configured.</ansigreen>')
)
return # Exit early since aliases already exist
else:
# No existing aliases, show the normal setup flow
print_formatted_text(
HTML('<grey>Would you like to set up convenient shell aliases?</grey>')
)
print_formatted_text('')
print_formatted_text(
HTML(
'<grey>This will add the following aliases to your shell profile:</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text('')
print_formatted_text(
HTML(
'<ansiyellow>⚠️ Note: This requires uv to be installed first.</ansiyellow>'
)
)
print_formatted_text(
HTML(
'<ansiyellow> Installation guide: https://docs.astral.sh/uv/getting-started/installation</ansiyellow>'
)
)
print_formatted_text('')
# Use cli_confirm to get user choice
choice = cli_confirm(
config,
'Set up shell aliases?',
['Yes, set up aliases', 'No, skip this step'],
)
if choice == 0: # User chose "Yes"
success = add_aliases_to_shell_config()
if success:
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Aliases added successfully!</ansigreen>')
)
# Get the appropriate reload command using the shell config manager
shell_manager = ShellConfigManager()
reload_cmd = shell_manager.get_reload_command()
print_formatted_text(
HTML(
f'<grey>Run <b>{reload_cmd}</b> (or restart your terminal) to use the new aliases.</grey>'
)
)
else:
print_formatted_text('')
print_formatted_text(
HTML(
'<ansired>❌ Failed to add aliases. You can set them up manually later.</ansired>'
)
)
else: # User chose "No"
print_formatted_text('')
print_formatted_text(
HTML(
'<grey>Skipped alias setup. You can run this setup again anytime.</grey>'
)
)
print_formatted_text('')
@@ -565,23 +583,15 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
finalize_config(config)
# Check if we should show the alias setup flow
# Only show it if:
# 1. Aliases don't exist in the shell configuration
# 2. User hasn't previously declined alias setup
# 3. We're in an interactive environment (not during tests or CI)
should_show_alias_setup = (
not aliases_exist_in_shell_config()
and not alias_setup_declined()
and sys.stdin.isatty()
)
if should_show_alias_setup:
# Clear the terminal if we haven't shown a banner yet (i.e., setup flow didn't run)
# Only show it if aliases don't exist in the shell configuration
# and we're in an interactive environment (not during tests or CI)
if not aliases_exist_in_shell_config() and sys.stdin.isatty():
# Clear the terminal if we haven't shown a banner yet
if not banner_shown:
clear()
run_alias_setup_flow(config)
# Don't set banner_shown = True here, so the ASCII art banner will still be shown
banner_shown = True
# TODO: Set working directory from config or use current working directory?
current_dir = config.workspace_base

View File

@@ -277,21 +277,3 @@ def get_shell_config_path() -> Path:
"""Get the path to the shell configuration file."""
manager = ShellConfigManager()
return manager.get_shell_config_path()
def alias_setup_declined() -> bool:
"""Check if the user has previously declined alias setup.
Returns:
True if user has declined alias setup, False otherwise.
"""
marker_file = Path.home() / '.openhands' / '.cli_alias_setup_declined'
return marker_file.exists()
def mark_alias_setup_declined() -> None:
"""Mark that the user has declined alias setup."""
openhands_dir = Path.home() / '.openhands'
openhands_dir.mkdir(exist_ok=True)
marker_file = openhands_dir / '.cli_alias_setup_declined'
marker_file.touch()

View File

@@ -42,13 +42,6 @@ def suppress_cli_warnings():
category=UserWarning,
)
# Suppress LiteLLM close_litellm_async_clients was never awaited warning
warnings.filterwarnings(
'ignore',
message="coroutine 'close_litellm_async_clients' was never awaited",
category=RuntimeWarning,
)
# Apply warning suppressions when module is imported
suppress_cli_warnings()

View File

@@ -9,6 +9,7 @@ from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
InstallationsService,
OwnerType,
ProviderType,
Repository,
@@ -20,7 +21,7 @@ from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class BitBucketService(BaseGitService, GitService):
class BitBucketService(BaseGitService, GitService, InstallationsService):
"""Default implementation of GitService for Bitbucket integration.
This is an extension point in OpenHands that allows applications to customize Bitbucket
@@ -185,7 +186,89 @@ class BitBucketService(BaseGitService, GitService):
return all_items[:max_items] # Trim to max_items if needed
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
async def get_installations(self) -> list[str]:
workspaces_url = f'{self.BASE_URL}/workspaces'
workspaces = await self._fetch_paginated_data(workspaces_url, {}, 100)
installations: list[str] = []
for workspace in workspaces:
installations.append(workspace['slug'])
return installations
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
) -> list[Repository]:
"""Get paginated repositories for a specific workspace.
Args:
page: The page number to fetch
per_page: The number of repositories per page
sort: The sort field ('pushed', 'updated', 'created', 'full_name')
installation_id: The workspace slug to fetch repositories from (as int, will be converted to string)
Returns:
A list of Repository objects
"""
if not installation_id:
return []
# Convert installation_id to string for use as workspace_slug
workspace_slug = installation_id
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
# Map sort parameter to Bitbucket API compatible values
bitbucket_sort = sort
if sort == 'pushed':
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
bitbucket_sort = '-updated_on' # Use negative prefix for descending order
elif sort == 'updated':
bitbucket_sort = '-updated_on'
elif sort == 'created':
bitbucket_sort = '-created_on'
elif sort == 'full_name':
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
else:
# Default to most recently updated first
bitbucket_sort = '-updated_on'
params = {
'pagelen': per_page,
'page': page,
'sort': bitbucket_sort,
}
response, headers = await self._make_request(workspace_repos_url, params)
# Extract repositories from the response
repos = response.get('values', [])
# Extract link header for pagination
next_link = response.get('next', '')
repositories = [
Repository(
id=repo.get('uuid', ''),
full_name=f'{repo.get("workspace", {}).get("slug", "")}/{repo.get("slug", "")}',
git_provider=ProviderType.BITBUCKET,
is_public=repo.get('is_private', True) is False,
stargazers_count=None, # Bitbucket doesn't have stars
pushed_at=repo.get('updated_on'),
owner_type=(
OwnerType.ORGANIZATION
if repo.get('workspace', {}).get('is_private') is False
else OwnerType.USER
),
link_header=next_link,
)
for repo in repos
]
return repositories
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
"""Get repositories for the authenticated user using workspaces endpoint.
This method gets all repositories (both public and private) that the user has access to

View File

@@ -15,6 +15,7 @@ from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
InstallationsService,
OwnerType,
ProviderType,
Repository,
@@ -28,7 +29,7 @@ from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class GitHubService(BaseGitService, GitService):
class GitHubService(BaseGitService, GitService, InstallationsService):
"""Default implementation of GitService for GitHub integration.
TODO: This doesn't seem a good candidate for the get_impl() pattern. What are the abstract methods we should actually separate and implement here?
@@ -192,14 +193,47 @@ class GitHubService(BaseGitService, GitService):
ts = repo.get('pushed_at')
return datetime.strptime(ts, '%Y-%m-%dT%H:%M:%SZ') if ts else datetime.min
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
):
params = {'page': str(page), 'per_page': str(per_page)}
if installation_id:
url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
response, headers = await self._make_request(url, params)
response = response.get('repositories', [])
else:
url = f'{self.BASE_URL}/user/repos'
params['sort'] = sort
response, headers = await self._make_request(url, params)
next_link: str = headers.get('Link', '')
return [
Repository(
id=str(repo.get('id')), # type: ignore[arg-type]
full_name=repo.get('full_name'), # type: ignore[arg-type]
stargazers_count=repo.get('stargazers_count'),
git_provider=ProviderType.GITHUB,
is_public=not repo.get('private', True),
owner_type=(
OwnerType.ORGANIZATION
if repo.get('owner', {}).get('type') == 'Organization'
else OwnerType.USER
),
link_header=next_link,
)
for repo in response
]
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitHub API
all_repos: list[dict] = []
if app_mode == AppMode.SAAS:
# Get all installation IDs and fetch repos for each one
installation_ids = await self.get_installation_ids()
installation_ids = await self.get_installations()
# Iterate through each installation ID
for installation_id in installation_ids:
@@ -246,11 +280,11 @@ class GitHubService(BaseGitService, GitService):
for repo in all_repos
]
async def get_installation_ids(self) -> list[int]:
async def get_installations(self) -> list[str]:
url = f'{self.BASE_URL}/user/installations'
response, _ = await self._make_request(url)
installations = response.get('installations', [])
return [i['id'] for i in installations]
return [str(i['id']) for i in installations]
async def search_repositories(
self, query: str, per_page: int, sort: str, order: str

View File

@@ -226,7 +226,49 @@ class GitLabService(BaseGitService, GitService):
return repos
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
) -> list[Repository]:
url = f'{self.BASE_URL}/projects'
order_by = {
'pushed': 'last_activity_at',
'updated': 'last_activity_at',
'created': 'created_at',
'full_name': 'name',
}.get(sort, 'last_activity_at')
params = {
'page': str(page),
'per_page': str(per_page),
'order_by': order_by,
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
'owned': True, # Boolean value without quotes
'membership': True, # Include projects user is a member of
}
response, headers = await self._make_request(url, params)
next_link: str = headers.get('Link', '')
repos = [
Repository(
id=str(repo.get('id')), # type: ignore[arg-type]
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=repo.get('visibility') == 'public',
owner_type=(
OwnerType.ORGANIZATION
if repo.get('namespace', {}).get('kind') == 'group'
else OwnerType.USER
),
link_header=next_link,
)
for repo in response
]
return repos
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitLab API
all_repos: list[dict] = []

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from types import MappingProxyType
from typing import Annotated, Any, Coroutine, Literal, overload
from typing import Annotated, Any, Coroutine, Literal, cast, overload
from pydantic import (
BaseModel,
@@ -22,6 +22,7 @@ from openhands.integrations.service_types import (
AuthenticationError,
Branch,
GitService,
InstallationsService,
ProviderType,
Repository,
SuggestedTask,
@@ -160,16 +161,61 @@ class ProviderHandler:
service = self._get_service(provider)
return await service.get_latest_token()
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
async def get_github_installations(self) -> list[str]:
service = cast(InstallationsService, self._get_service(ProviderType.GITHUB))
try:
return await service.get_installations()
except Exception as e:
logger.warning(f'Failed to get github installations {e}')
return []
async def get_bitbucket_workspaces(self) -> list[str]:
service = cast(InstallationsService, self._get_service(ProviderType.BITBUCKET))
try:
return await service.get_installations()
except Exception as e:
logger.warning(f'Failed to get bitbucket workspaces {e}')
return []
async def get_repositories(
self,
sort: str,
app_mode: AppMode,
selected_provider: ProviderType | None,
page: int | None,
per_page: int | None,
installation_id: str | None,
) -> list[Repository]:
"""
Get repositories from providers
"""
"""
Get repositories from providers
"""
if selected_provider:
if not page or not per_page:
logger.error('Failed to provider params for paginating repos')
return []
service = self._get_service(selected_provider)
try:
return await service.get_paginated_repos(
page, per_page, sort, installation_id
)
except Exception as e:
logger.warning(f'Error fetching repos from {selected_provider}: {e}')
return []
all_repos: list[Repository] = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service_repos = await service.get_repositories(sort, app_mode)
service_repos = await service.get_all_repositories(sort, app_mode)
all_repos.extend(service_repos)
except Exception as e:
logger.warning(f'Error fetching repos from {provider}: {e}')

View File

@@ -200,6 +200,12 @@ class BaseGitService(ABC):
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
class InstallationsService(Protocol):
async def get_installations(self) -> list[str]:
"""Get installations for the service; repos live underneath these installations"""
...
class GitService(Protocol):
"""Protocol defining the interface for Git service providers"""
@@ -233,10 +239,18 @@ class GitService(Protocol):
"""Search for repositories"""
...
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
"""Get repositories for the authenticated user"""
...
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
) -> list[Repository]:
"""Get a page of repositories for the authenticated user"""
...
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories"""
...

View File

@@ -72,10 +72,7 @@ from openhands.runtime.utils.bash import BashSession
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
from openhands.runtime.utils.system_stats import (
get_system_stats,
update_last_execution_time,
)
from openhands.runtime.utils.system_stats import get_system_stats
from openhands.utils.async_utils import call_sync_from_async, wait_all
if sys.platform == 'win32':
@@ -847,8 +844,6 @@ if __name__ == '__main__':
status_code=500,
detail=traceback.format_exc(),
)
finally:
update_last_execution_time()
@app.post('/update_mcp_server')
async def update_mcp_server(request: Request):

View File

@@ -46,7 +46,6 @@ from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.runtime.base import Runtime
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils.request import send_request
from openhands.runtime.utils.system_stats import update_last_execution_time
from openhands.utils.http_session import HttpSession
from openhands.utils.tenacity_stop import stop_if_should_exit
@@ -329,8 +328,6 @@ class ActionExecutionClient(Runtime):
raise AgentRuntimeTimeoutError(
f'Runtime failed to return execute_action before the requested timeout of {action.timeout}s'
)
finally:
update_last_execution_time()
return obs
def run(self, action: CmdRunAction) -> Observation:

View File

@@ -31,7 +31,6 @@ from openhands.runtime.utils.command import (
get_action_execution_server_startup_command,
)
from openhands.runtime.utils.log_streamer import LogStreamer
from openhands.runtime.utils.port_lock import PortLock, find_available_port_with_lock
from openhands.runtime.utils.runtime_build import build_runtime_image
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.shutdown_listener import add_shutdown_listener
@@ -105,11 +104,6 @@ class DockerRuntime(ActionExecutionClient):
self._vscode_port = -1
self._app_ports: list[int] = []
# Port locks to prevent race conditions
self._host_port_lock: PortLock | None = None
self._vscode_port_lock: PortLock | None = None
self._app_port_locks: list[PortLock] = []
if os.environ.get('DOCKER_HOST_ADDR'):
logger.info(
f'Using DOCKER_HOST_IP: {os.environ["DOCKER_HOST_ADDR"]} for local_runtime_url'
@@ -216,7 +210,6 @@ class DockerRuntime(ActionExecutionClient):
extra_deps=self.config.sandbox.runtime_extra_deps,
force_rebuild=self.config.sandbox.force_rebuild_runtime,
extra_build_args=self.config.sandbox.runtime_extra_build_args,
enable_browser=self.config.enable_browser,
)
@staticmethod
@@ -282,31 +275,17 @@ class DockerRuntime(ActionExecutionClient):
def init_container(self) -> None:
self.log('debug', 'Preparing to start container...')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
# Allocate host port with locking to prevent race conditions
self._host_port, self._host_port_lock = self._find_available_port_with_lock(
EXECUTION_SERVER_PORT_RANGE
)
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
self._container_port = self._host_port
# Use the configured vscode_port if provided, otherwise find an available port
if self.config.sandbox.vscode_port:
self._vscode_port = self.config.sandbox.vscode_port
self._vscode_port_lock = None # No lock needed for configured port
else:
self._vscode_port, self._vscode_port_lock = (
self._find_available_port_with_lock(VSCODE_PORT_RANGE)
)
# Allocate app ports with locking
app_port_1, app_lock_1 = self._find_available_port_with_lock(APP_PORT_RANGE_1)
app_port_2, app_lock_2 = self._find_available_port_with_lock(APP_PORT_RANGE_2)
self._app_ports = [app_port_1, app_port_2]
self._app_port_locks = [
lock for lock in [app_lock_1, app_lock_2] if lock is not None
self._vscode_port = (
self.config.sandbox.vscode_port
or self._find_available_port(VSCODE_PORT_RANGE)
)
self._app_ports = [
self._find_available_port(APP_PORT_RANGE_1),
self._find_available_port(APP_PORT_RANGE_2),
]
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
use_host_network = self.config.sandbox.use_host_network
@@ -496,28 +475,6 @@ class DockerRuntime(ActionExecutionClient):
CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name
)
stop_all_containers(close_prefix)
self._release_port_locks()
def _release_port_locks(self) -> None:
"""Release all acquired port locks."""
if self._host_port_lock:
self._host_port_lock.release()
self._host_port_lock = None
logger.debug(f'Released host port lock for port {self._host_port}')
if self._vscode_port_lock:
self._vscode_port_lock.release()
self._vscode_port_lock = None
logger.debug(f'Released VSCode port lock for port {self._vscode_port}')
for i, lock in enumerate(self._app_port_locks):
if lock:
lock.release()
logger.debug(
f'Released app port lock for port {self._app_ports[i] if i < len(self._app_ports) else "unknown"}'
)
self._app_port_locks.clear()
def _is_port_in_use_docker(self, port: int) -> bool:
containers = self.docker_client.containers.list()
@@ -527,58 +484,15 @@ class DockerRuntime(ActionExecutionClient):
return True
return False
def _find_available_port_with_lock(
self, port_range: tuple[int, int], max_attempts: int = 5
) -> tuple[int, PortLock | None]:
"""Find an available port with race condition protection.
This method uses file-based locking to prevent multiple workers
from allocating the same port simultaneously.
Args:
port_range: Tuple of (min_port, max_port)
max_attempts: Maximum number of attempts to find a port
Returns:
Tuple of (port_number, port_lock) where port_lock may be None if locking failed
"""
# Try to find and lock an available port
result = find_available_port_with_lock(
min_port=port_range[0],
max_port=port_range[1],
max_attempts=max_attempts,
bind_address='0.0.0.0',
lock_timeout=1.0,
)
if result is None:
# Fallback to original method if port locking fails
logger.warning(
f'Port locking failed for range {port_range}, falling back to original method'
)
port = port_range[1]
for _ in range(max_attempts):
port = find_available_tcp_port(port_range[0], port_range[1])
if not self._is_port_in_use_docker(port):
return port, None
return port, None
port, port_lock = result
# Additional check with Docker to ensure port is not in use
if self._is_port_in_use_docker(port):
port_lock.release()
# Try again with a different port
logger.debug(f'Port {port} is in use by Docker, trying again')
return self._find_available_port_with_lock(port_range, max_attempts - 1)
return port, port_lock
def _find_available_port(
self, port_range: tuple[int, int], max_attempts: int = 5
) -> int:
"""Find an available port (legacy method for backward compatibility)."""
port, _ = self._find_available_port_with_lock(port_range, max_attempts)
port = port_range[1]
for _ in range(max_attempts):
port = find_available_tcp_port(port_range[0], port_range[1])
if not self._is_port_in_use_docker(port):
return port
# If no port is found after max_attempts, return the last tried port
return port
@property

View File

@@ -623,16 +623,8 @@ def _create_server(
os.getenv('VSCODE_PORT') or str(find_available_tcp_port(*VSCODE_PORT_RANGE))
)
app_ports = [
int(
os.getenv('WORK_PORT_1')
or os.getenv('APP_PORT_1')
or str(find_available_tcp_port(*APP_PORT_RANGE_1))
),
int(
os.getenv('WORK_PORT_2')
or os.getenv('APP_PORT_2')
or str(find_available_tcp_port(*APP_PORT_RANGE_2))
),
int(os.getenv('APP_PORT_1') or str(find_available_tcp_port(*APP_PORT_RANGE_1))),
int(os.getenv('APP_PORT_2') or str(find_available_tcp_port(*APP_PORT_RANGE_2))),
]
# Get user info

View File

@@ -250,7 +250,6 @@ class RemoteRuntime(ActionExecutionClient):
platform=self.config.sandbox.platform,
extra_deps=self.config.sandbox.runtime_extra_deps,
force_rebuild=self.config.sandbox.force_rebuild_runtime,
enable_browser=self.config.enable_browser,
)
response = self._send_runtime_api_request(

View File

@@ -4,7 +4,8 @@ from typing import Callable
@dataclass
class CommandResult:
"""Represents the result of a shell command execution.
"""
Represents the result of a shell command execution.
Attributes:
content (str): The output content of the command.
@@ -16,7 +17,9 @@ class CommandResult:
class GitHandler:
"""A handler for executing Git-related operations via shell commands."""
"""
A handler for executing Git-related operations via shell commands.
"""
def __init__(
self,
@@ -26,7 +29,8 @@ class GitHandler:
self.cwd: str | None = None
def set_cwd(self, cwd: str) -> None:
"""Sets the current working directory for Git operations.
"""
Sets the current working directory for Git operations.
Args:
cwd (str): The directory path.
@@ -34,7 +38,8 @@ class GitHandler:
self.cwd = cwd
def _is_git_repo(self) -> bool:
"""Checks if the current directory is a Git repository.
"""
Checks if the current directory is a Git repository.
Returns:
bool: True if inside a Git repository, otherwise False.
@@ -44,7 +49,8 @@ class GitHandler:
return output.content.strip() == 'true'
def _get_current_file_content(self, file_path: str) -> str:
"""Retrieves the current content of a given file.
"""
Retrieves the current content of a given file.
Args:
file_path (str): Path to the file.
@@ -56,7 +62,8 @@ class GitHandler:
return output.content
def _verify_ref_exists(self, ref: str) -> bool:
"""Verifies whether a specific Git reference exists.
"""
Verifies whether a specific Git reference exists.
Args:
ref (str): The Git reference to check.
@@ -68,71 +75,10 @@ class GitHandler:
output = self.execute(cmd, self.cwd)
return output.exit_code == 0
def _is_ahead_of_remote_branch(self, remote_branch: str) -> bool:
"""Checks if the current branch is ahead of the specified remote branch.
Args:
remote_branch (str): The remote branch reference (e.g., 'origin/feature-branch').
Returns:
bool: True if current branch is ahead, False otherwise.
"""
cmd = f'git --no-pager rev-list --count {remote_branch}..HEAD'
output = self.execute(cmd, self.cwd)
if output.exit_code != 0:
return False
return int(output.content.strip()) > 0
def _includes_merged_main_commits(self, remote_branch: str, default_branch: str) -> bool:
"""Checks if the local branch includes commits that were merged from the default branch.
Since the remote branch was last updated.
Args:
remote_branch (str): The remote branch reference (e.g., 'origin/feature-branch').
default_branch (str): The default branch name (e.g., 'main').
Returns:
bool: True if merged main commits are included in the diff.
"""
# Get commits that are in HEAD but not in remote_branch
cmd = f'git --no-pager log --oneline {remote_branch}..HEAD'
output = self.execute(cmd, self.cwd)
if output.exit_code != 0:
return False
local_commits = output.content.strip().splitlines()
if not local_commits:
return False
# Get commits that are in origin/default_branch but not in remote_branch
origin_default = f'origin/{default_branch}'
if not self._verify_ref_exists(origin_default):
return False
cmd = f'git --no-pager log --oneline {remote_branch}..{origin_default}'
output = self.execute(cmd, self.cwd)
if output.exit_code != 0:
return False
main_commits = output.content.strip().splitlines()
if not main_commits:
return False
# Extract commit hashes from both lists
local_hashes = {line.split()[0] for line in local_commits if line.strip()}
main_hashes = {line.split()[0] for line in main_commits if line.strip()}
# If there's significant overlap, we likely have merged main commits
overlap = local_hashes.intersection(main_hashes)
return len(overlap) >= min(2, len(main_hashes) // 2)
def _get_valid_ref(self) -> str | None:
"""Determines a valid Git reference for comparison using a hybrid approach.
- Uses origin/current_branch when it's the best representation of push status
- Falls back to merge-base when origin/current_branch includes merged main commits
"""
Determines a valid Git reference for comparison.
Returns:
str | None: A valid Git reference or None if no valid reference is found.
"""
@@ -144,19 +90,8 @@ class GitHandler:
ref_default_branch = 'origin/' + default_branch
ref_new_repo = '$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
# Hybrid logic: check if origin/current_branch exists and causes pollution
if self._verify_ref_exists(ref_current_branch):
# If we're ahead of remote and it includes merged main commits, use merge-base instead
if (self._is_ahead_of_remote_branch(ref_current_branch) and
self._includes_merged_main_commits(ref_current_branch, default_branch)):
# Try merge-base first to avoid pollution
if self._verify_ref_exists(ref_non_default_branch):
return ref_non_default_branch
# Otherwise use origin/current_branch for normal push workflow
return ref_current_branch
# Fallback to original logic
refs = [
ref_current_branch,
ref_non_default_branch,
ref_default_branch,
ref_new_repo,
@@ -168,7 +103,8 @@ class GitHandler:
return None
def _get_ref_content(self, file_path: str) -> str:
"""Retrieves the content of a file from a valid Git reference.
"""
Retrieves the content of a file from a valid Git reference.
Args:
file_path (str): The file path in the repository.
@@ -185,7 +121,8 @@ class GitHandler:
return output.content if output.exit_code == 0 else ''
def _get_default_branch(self) -> str:
"""Retrieves the primary Git branch name of the repository.
"""
Retrieves the primary Git branch name of the repository.
Returns:
str: The name of the primary branch.
@@ -195,7 +132,8 @@ class GitHandler:
return output.content.split()[-1].strip()
def _get_current_branch(self) -> str:
"""Retrieves the currently selected Git branch.
"""
Retrieves the currently selected Git branch.
Returns:
str: The name of the current branch.
@@ -205,7 +143,8 @@ class GitHandler:
return output.content.strip()
def _get_changed_files(self) -> list[str]:
"""Retrieves a list of changed files compared to a valid Git reference.
"""
Retrieves a list of changed files compared to a valid Git reference.
Returns:
list[str]: A list of changed file paths.
@@ -223,7 +162,8 @@ class GitHandler:
return output.content.splitlines()
def _get_untracked_files(self) -> list[dict[str, str]]:
"""Retrieves a list of untracked files in the repository. This is useful for detecting new files.
"""
Retrieves a list of untracked files in the repository. This is useful for detecting new files.
Returns:
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
@@ -238,7 +178,8 @@ class GitHandler:
)
def get_git_changes(self) -> list[dict[str, str]] | None:
"""Retrieves the list of changed files in the Git repository.
"""
Retrieves the list of changed files in the Git repository.
Returns:
list[dict[str, str]] | None: A list of dictionaries containing file paths and statuses. None if not a git repository.
@@ -254,7 +195,8 @@ class GitHandler:
return result
def get_git_diff(self, file_path: str) -> dict[str, str]:
"""Retrieves the original and modified content of a file in the repository.
"""
Retrieves the original and modified content of a file in the repository.
Args:
file_path (str): Path to the file.
@@ -272,7 +214,8 @@ class GitHandler:
def parse_git_changes(changes_list: list[str]) -> list[dict[str, str]]:
"""Parses the list of changed files and extracts their statuses and paths.
"""
Parses the list of changed files and extracts their statuses and paths.
Args:
changes_list (list[str]): List of changed file entries.

View File

@@ -1,268 +0,0 @@
"""File-based port locking system for preventing race conditions in port allocation."""
import os
import random
import socket
import tempfile
import time
from typing import Optional
from openhands.core.logger import openhands_logger as logger
# Import fcntl only on Unix systems
try:
import fcntl
HAS_FCNTL = True
except ImportError:
HAS_FCNTL = False
class PortLock:
"""File-based lock for a specific port to prevent race conditions."""
def __init__(self, port: int, lock_dir: Optional[str] = None):
self.port = port
self.lock_dir = lock_dir or os.path.join(
tempfile.gettempdir(), 'openhands_port_locks'
)
self.lock_file_path = os.path.join(self.lock_dir, f'port_{port}.lock')
self.lock_fd: Optional[int] = None
self._locked = False
# Ensure lock directory exists
os.makedirs(self.lock_dir, exist_ok=True)
def acquire(self, timeout: float = 1.0) -> bool:
"""Acquire the lock for this port.
Args:
timeout: Maximum time to wait for the lock
Returns:
True if lock was acquired, False otherwise
"""
if self._locked:
return True
try:
if HAS_FCNTL:
# Unix-style file locking with fcntl
self.lock_fd = os.open(
self.lock_file_path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC
)
# Try to acquire exclusive lock with timeout
start_time = time.time()
while time.time() - start_time < timeout:
try:
fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
self._locked = True
# Write port number to lock file for debugging
os.write(self.lock_fd, f'{self.port}\n'.encode())
os.fsync(self.lock_fd)
logger.debug(f'Acquired lock for port {self.port}')
return True
except (OSError, IOError):
# Lock is held by another process, wait a bit
time.sleep(0.01)
# Timeout reached
if self.lock_fd:
os.close(self.lock_fd)
self.lock_fd = None
return False
else:
# Windows fallback: use atomic file creation
start_time = time.time()
while time.time() - start_time < timeout:
try:
# Try to create lock file exclusively
self.lock_fd = os.open(
self.lock_file_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY
)
self._locked = True
# Write port number to lock file for debugging
os.write(self.lock_fd, f'{self.port}\n'.encode())
os.fsync(self.lock_fd)
logger.debug(f'Acquired lock for port {self.port}')
return True
except OSError:
# Lock file already exists, wait a bit
time.sleep(0.01)
# Timeout reached
return False
except Exception as e:
logger.debug(f'Failed to acquire lock for port {self.port}: {e}')
if self.lock_fd:
try:
os.close(self.lock_fd)
except OSError:
pass
self.lock_fd = None
return False
def release(self) -> None:
"""Release the lock."""
if self.lock_fd is not None:
try:
if HAS_FCNTL:
# Unix: unlock and close
fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
os.close(self.lock_fd)
# Remove lock file (both Unix and Windows)
try:
os.unlink(self.lock_file_path)
except FileNotFoundError:
pass
logger.debug(f'Released lock for port {self.port}')
except Exception as e:
logger.warning(f'Error releasing lock for port {self.port}: {e}')
finally:
self.lock_fd = None
self._locked = False
def __enter__(self) -> 'PortLock':
if not self.acquire():
raise OSError(f'Could not acquire lock for port {self.port}')
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.release()
@property
def is_locked(self) -> bool:
return self._locked
def find_available_port_with_lock(
min_port: int = 30000,
max_port: int = 39999,
max_attempts: int = 20,
bind_address: str = '0.0.0.0',
lock_timeout: float = 1.0,
) -> Optional[tuple[int, PortLock]]:
"""Find an available port and acquire a lock for it.
This function combines file-based locking with port availability checking
to prevent race conditions in multi-process scenarios.
Args:
min_port: Minimum port number to try
max_port: Maximum port number to try
max_attempts: Maximum number of ports to try
bind_address: Address to bind to when checking availability
lock_timeout: Timeout for acquiring port lock
Returns:
Tuple of (port, lock) if successful, None otherwise
"""
rng = random.SystemRandom()
# Try random ports first for better distribution
random_attempts = min(max_attempts // 2, 10)
for _ in range(random_attempts):
port = rng.randint(min_port, max_port)
# Try to acquire lock first
lock = PortLock(port)
if lock.acquire(timeout=lock_timeout):
# Check if port is actually available
if _check_port_available(port, bind_address):
logger.debug(f'Found and locked available port {port}')
return port, lock
else:
# Port is locked but not available (maybe in TIME_WAIT state)
lock.release()
# Small delay to reduce contention
time.sleep(0.001)
# If random attempts failed, try sequential search
remaining_attempts = max_attempts - random_attempts
start_port = rng.randint(min_port, max_port - remaining_attempts)
for i in range(remaining_attempts):
port = start_port + i
if port > max_port:
port = min_port + (port - max_port - 1)
# Try to acquire lock first
lock = PortLock(port)
if lock.acquire(timeout=lock_timeout):
# Check if port is actually available
if _check_port_available(port, bind_address):
logger.debug(f'Found and locked available port {port}')
return port, lock
else:
# Port is locked but not available
lock.release()
# Small delay to reduce contention
time.sleep(0.001)
logger.error(
f'Could not find and lock available port in range {min_port}-{max_port} after {max_attempts} attempts'
)
return None
def _check_port_available(port: int, bind_address: str = '0.0.0.0') -> bool:
"""Check if a port is available by trying to bind to it."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((bind_address, port))
sock.close()
return True
except OSError:
return False
def cleanup_stale_locks(max_age_seconds: int = 300) -> int:
"""Clean up stale lock files.
Args:
max_age_seconds: Maximum age of lock files before they're considered stale
Returns:
Number of lock files cleaned up
"""
lock_dir = os.path.join(tempfile.gettempdir(), 'openhands_port_locks')
if not os.path.exists(lock_dir):
return 0
cleaned = 0
current_time = time.time()
try:
for filename in os.listdir(lock_dir):
if filename.startswith('port_') and filename.endswith('.lock'):
lock_path = os.path.join(lock_dir, filename)
try:
# Check if lock file is old
stat = os.stat(lock_path)
if current_time - stat.st_mtime > max_age_seconds:
# Try to remove stale lock
os.unlink(lock_path)
cleaned += 1
logger.debug(f'Cleaned up stale lock file: {filename}')
except (OSError, FileNotFoundError):
# File might have been removed by another process
pass
except OSError:
# Directory might not exist or be accessible
pass
if cleaned > 0:
logger.info(f'Cleaned up {cleaned} stale port lock files')
return cleaned

View File

@@ -32,7 +32,6 @@ def _generate_dockerfile(
base_image: str,
build_from: BuildFromImageType = BuildFromImageType.SCRATCH,
extra_deps: str | None = None,
enable_browser: bool = True,
) -> str:
"""Generate the Dockerfile content for the runtime image based on the base image.
@@ -40,7 +39,6 @@ def _generate_dockerfile(
- base_image (str): The base image provided for the runtime image
- build_from (BuildFromImageType): The build method for the runtime image.
- extra_deps (str):
- enable_browser (bool): Whether to enable browser support (install Playwright)
Returns:
- str: The resulting Dockerfile content
@@ -57,7 +55,6 @@ def _generate_dockerfile(
build_from_scratch=build_from == BuildFromImageType.SCRATCH,
build_from_versioned=build_from == BuildFromImageType.VERSIONED,
extra_deps=extra_deps if extra_deps is not None else '',
enable_browser=enable_browser,
)
return dockerfile_content
@@ -114,7 +111,6 @@ def build_runtime_image(
dry_run: bool = False,
force_rebuild: bool = False,
extra_build_args: list[str] | None = None,
enable_browser: bool = True,
) -> str:
"""Prepares the final docker build folder.
@@ -129,7 +125,6 @@ def build_runtime_image(
- dry_run (bool): if True, it will only ready the build folder. It will not actually build the Docker image
- force_rebuild (bool): if True, it will create the Dockerfile which uses the base_image
- extra_build_args (List[str]): Additional build arguments to pass to the builder
- enable_browser (bool): Whether to enable browser support (install Playwright)
Returns:
- str: <image_repo>:<MD5 hash>. Where MD5 hash is the hash of the docker build folder
@@ -147,7 +142,6 @@ def build_runtime_image(
force_rebuild=force_rebuild,
platform=platform,
extra_build_args=extra_build_args,
enable_browser=enable_browser,
)
return result
@@ -160,7 +154,6 @@ def build_runtime_image(
force_rebuild=force_rebuild,
platform=platform,
extra_build_args=extra_build_args,
enable_browser=enable_browser,
)
return result
@@ -174,10 +167,9 @@ def build_runtime_image_in_folder(
force_rebuild: bool,
platform: str | None = None,
extra_build_args: list[str] | None = None,
enable_browser: bool = True,
) -> str:
runtime_image_repo, _ = get_runtime_image_repo_and_tag(base_image)
lock_tag = f'oh_v{oh_version}_{get_hash_for_lock_files(base_image, enable_browser)}'
lock_tag = f'oh_v{oh_version}_{get_hash_for_lock_files(base_image)}'
versioned_tag = (
# truncate the base image to 96 characters to fit in the tag max length (128 characters)
f'oh_v{oh_version}_{get_tag_for_versioned_image(base_image)}'
@@ -196,7 +188,6 @@ def build_runtime_image_in_folder(
base_image,
build_from=BuildFromImageType.SCRATCH,
extra_deps=extra_deps,
enable_browser=enable_browser,
)
if not dry_run:
_build_sandbox_image(
@@ -235,7 +226,7 @@ def build_runtime_image_in_folder(
else:
logger.debug(f'Build [{hash_image_name}] from scratch')
prep_build_folder(build_folder, base_image, build_from, extra_deps, enable_browser)
prep_build_folder(build_folder, base_image, build_from, extra_deps)
if not dry_run:
_build_sandbox_image(
build_folder,
@@ -260,7 +251,6 @@ def prep_build_folder(
base_image: str,
build_from: BuildFromImageType,
extra_deps: str | None,
enable_browser: bool = True,
) -> None:
# Copy the source code to directory. It will end up in build_folder/code
# If package is not found, build from source code
@@ -292,7 +282,6 @@ def prep_build_folder(
base_image,
build_from=build_from,
extra_deps=extra_deps,
enable_browser=enable_browser,
)
dockerfile_path = Path(build_folder, 'Dockerfile')
with open(str(dockerfile_path), 'w') as f:
@@ -312,13 +301,10 @@ def truncate_hash(hash: str) -> str:
return ''.join(result)
def get_hash_for_lock_files(base_image: str, enable_browser: bool = True) -> str:
def get_hash_for_lock_files(base_image: str) -> str:
openhands_source_dir = Path(openhands.__file__).parent
md5 = hashlib.md5()
md5.update(base_image.encode())
# Only include enable_browser in hash when it's False for backward compatibility
if not enable_browser:
md5.update(str(enable_browser).encode())
for file in ['pyproject.toml', 'poetry.lock']:
src = Path(openhands_source_dir, file)
if not src.exists():
@@ -392,10 +378,6 @@ if __name__ == '__main__':
parser.add_argument('--build_folder', type=str, default=None)
parser.add_argument('--force_rebuild', action='store_true', default=False)
parser.add_argument('--platform', type=str, default=None)
parser.add_argument('--enable_browser', action='store_true', default=True)
parser.add_argument(
'--no_enable_browser', dest='enable_browser', action='store_false'
)
args = parser.parse_args()
if args.build_folder is not None:
@@ -427,7 +409,6 @@ if __name__ == '__main__':
dry_run=True,
force_rebuild=args.force_rebuild,
platform=args.platform,
enable_browser=args.enable_browser,
)
_runtime_image_repo, runtime_image_source_tag = (
@@ -463,9 +444,6 @@ if __name__ == '__main__':
logger.debug('Building image in a temporary folder')
docker_builder = DockerRuntimeBuilder(docker.from_env())
image_name = build_runtime_image(
args.base_image,
docker_builder,
platform=args.platform,
enable_browser=args.enable_browser,
args.base_image, docker_builder, platform=args.platform
)
logger.debug(f'\nBuilt image: {image_name}\n')

View File

@@ -127,9 +127,7 @@ RUN \
/openhands/micromamba/bin/micromamba run -n openhands poetry install --only main,runtime --no-interaction --no-root && \
# Update and install additional tools
# (There used to be an "apt-get update" here, hopefully we can skip it.)
{% if enable_browser %}
/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install --with-deps chromium && \
{% endif %}
# Set environment variables
/openhands/micromamba/bin/micromamba run -n openhands poetry run python -c "import sys; print('OH_INTERPRETER_PATH=' + sys.executable)" >> /etc/environment && \
# Set permissions

View File

@@ -4,25 +4,6 @@ import time
import psutil
_start_time = time.time()
_last_execution_time = time.time()
def get_system_info() -> dict[str, object]:
current_time = time.time()
uptime = current_time - _start_time
idle_time = current_time - _last_execution_time
return {
'uptime': uptime,
'idle_time': idle_time,
'resources': get_system_stats(),
}
def update_last_execution_time():
global _last_execution_time
_last_execution_time = time.time()
def get_system_stats() -> dict[str, object]:
"""Get current system resource statistics.

View File

@@ -27,4 +27,3 @@ class ConversationInfo:
url: str | None = None
session_api_key: str | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
pr_number: list[int] = field(default_factory=list)

View File

@@ -38,9 +38,55 @@ from openhands.server.user_auth import (
app = APIRouter(prefix='/api/user', dependencies=get_dependencies())
@app.get('/github/installations', response_model=list[str])
async def get_user_github_installations(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
return await client.get_github_installations()
return JSONResponse(
content='Git provider token required. (such as GitHub).',
status_code=status.HTTP_401_UNAUTHORIZED,
)
@app.get('/bitbucket/installations', response_model=list[str])
async def get_user_bitbucket_installations(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
return await client.get_github_installations()
return JSONResponse(
content='Git provider token required. (such as GitHub).',
status_code=status.HTTP_401_UNAUTHORIZED,
)
@app.get('/repositories', response_model=list[Repository])
async def get_user_repositories(
sort: str = 'pushed',
selected_provider: ProviderType | None = None,
page: int | None = None,
per_page: int | None = None,
installation_id: str | None = None,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
@@ -53,7 +99,14 @@ async def get_user_repositories(
)
try:
return await client.get_repositories(sort, server_config.app_mode)
return await client.get_repositories(
sort,
server_config.app_mode,
selected_provider,
page,
per_page,
installation_id,
)
except AuthenticationError as e:
logger.info(
@@ -254,7 +307,6 @@ class MicroagentResponse(BaseModel):
tools: list[str] = []
created_at: datetime
git_provider: ProviderType
path: str # Path to the microagent in the Git provider (e.g., ".openhands/microagents/tell-me-a-joke")
def _get_file_creation_time(repo_dir: Path, file_path: Path) -> datetime:
@@ -454,7 +506,6 @@ def _process_microagents(
),
created_at=created_at,
git_provider=git_provider,
path=str(agent_file_path.relative_to(repo_dir)),
)
)
@@ -478,7 +529,6 @@ def _process_microagents(
),
created_at=created_at,
git_provider=git_provider,
path=str(agent_file_path.relative_to(repo_dir)),
)
)

View File

@@ -1,6 +1,11 @@
from fastapi import FastAPI
import time
from openhands.runtime.utils.system_stats import get_system_info
from fastapi import FastAPI, Request
from openhands.runtime.utils.system_stats import get_system_stats
start_time = time.time()
last_execution_time = start_time
def add_health_endpoints(app: FastAPI):
@@ -14,4 +19,20 @@ def add_health_endpoints(app: FastAPI):
@app.get('/server_info')
async def get_server_info():
return get_system_info()
current_time = time.time()
uptime = current_time - start_time
idle_time = current_time - last_execution_time
response = {
'uptime': uptime,
'idle_time': idle_time,
'resources': get_system_stats(),
}
return response
@app.middleware('http')
async def update_last_execution_time(request: Request, call_next):
global last_execution_time
response = await call_next(request)
last_execution_time = time.time()
return response

View File

@@ -424,7 +424,6 @@ async def _get_conversation_info(
num_connections=num_connections,
url=agent_loop_info.url if agent_loop_info else None,
session_api_key=getattr(agent_loop_info, 'session_api_key', None),
pr_number=conversation.pr_number,
)
except Exception as e:
logger.error(

View File

@@ -73,9 +73,9 @@ class FileConversationStore(ConversationStore):
metadata_dir = self.get_conversation_metadata_dir()
try:
conversation_ids = [
Path(path).name
path.split('/')[-2]
for path in self.file_store.list(metadata_dir)
if not Path(path).name.startswith('.')
if not path.startswith(f'{metadata_dir}/.')
]
except FileNotFoundError:
return ConversationMetadataResultSet([])

View File

@@ -212,7 +212,7 @@ def _load_runtime(
runtime_startup_env_vars: dict[str, str] | None = None,
docker_runtime_kwargs: dict[str, str] | None = None,
override_mcp_config: MCPConfig | None = None,
enable_browser: bool = False,
enable_browser: bool = True,
) -> tuple[Runtime, OpenHandsConfig]:
sid = 'rt_' + str(random.randint(100000, 999999))

View File

@@ -38,9 +38,7 @@ def test_view_file(temp_dir, runtime_cls, run_as_openhands):
def test_view_directory(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create test file
test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt')

View File

@@ -36,7 +36,6 @@ def test_browsergym_eval_env(runtime_cls, temp_dir):
base_container_image='xingyaoww/od-eval-miniwob:v1.0',
browsergym_eval_env='browsergym/miniwob.choose-list',
force_rebuild_runtime=True,
enable_browser=True,
)
from openhands.runtime.browser.browser_env import (
BROWSER_EVAL_GET_GOAL_ACTION,

View File

@@ -144,9 +144,7 @@ def test_browser_disabled(temp_dir, runtime_cls, run_as_openhands):
def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
# Test browse
action_cmd = CmdRunAction(command='python3 -m http.server 8000 > server.log 2>&1 &')
@@ -191,9 +189,7 @@ def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
def test_browser_navigation_actions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser navigation actions: goto, go_back, go_forward, noop."""
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create test HTML pages
page1_content = """
@@ -326,9 +322,7 @@ def test_browser_navigation_actions(temp_dir, runtime_cls, run_as_openhands):
def test_browser_form_interactions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser form interaction actions: fill, click, select_option, clear."""
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create a test form page
form_content = """
@@ -542,9 +536,7 @@ fill("{textarea_bid}", "This is a test message")
def test_browser_interactive_actions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser interactive actions: scroll, hover, fill, press, focus."""
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create a test page with scrollable content
scroll_content = """
@@ -750,9 +742,7 @@ scroll(0, 400)
def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands):
"""Test browser file upload action."""
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create a test file to upload
test_file_content = 'This is a test file for upload testing.'
@@ -907,9 +897,7 @@ def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands):
def test_read_pdf_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create a PDF file using reportlab in the host environment
from reportlab.lib.pagesizes import letter
@@ -981,9 +969,7 @@ def test_read_pdf_browse(temp_dir, runtime_cls, run_as_openhands):
def test_read_png_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create a PNG file using PIL in the host environment
from PIL import Image, ImageDraw
@@ -1051,9 +1037,7 @@ def test_read_png_browse(temp_dir, runtime_cls, run_as_openhands):
def test_download_file(temp_dir, runtime_cls, run_as_openhands):
"""Test downloading a file using the browser."""
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Minimal PDF content for testing
pdf_content = b"""%PDF-1.4

View File

@@ -128,11 +128,7 @@ async def test_fetch_mcp_via_stdio(temp_dir, runtime_cls, run_as_openhands):
)
override_mcp_config = MCPConfig(stdio_servers=[mcp_stdio_server_config])
runtime, config = _load_runtime(
temp_dir,
runtime_cls,
run_as_openhands,
override_mcp_config=override_mcp_config,
enable_browser=True,
temp_dir, runtime_cls, run_as_openhands, override_mcp_config=override_mcp_config
)
# Test browser server
@@ -224,7 +220,6 @@ async def test_both_stdio_and_sse_mcp(
runtime_cls,
run_as_openhands,
override_mcp_config=override_mcp_config,
enable_browser=True,
)
# ======= Test SSE server =======
@@ -302,7 +297,6 @@ async def test_microagent_and_one_stdio_mcp_in_config(
runtime_cls,
run_as_openhands,
override_mcp_config=override_mcp_config,
enable_browser=True,
)
# NOTE: this simulate the case where the microagent adds a new stdio server to the runtime

View File

@@ -1,219 +0,0 @@
"""Test for port allocation race condition fix."""
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from openhands.runtime.utils.port_lock import PortLock, find_available_port_with_lock
class TestPortLockingFix:
"""Test cases for port allocation race condition fix."""
def test_port_lock_prevents_duplicate_allocation(self):
"""Test that port locking prevents duplicate port allocation."""
allocated_ports = []
port_locks = []
def allocate_port():
"""Simulate port allocation by multiple workers."""
result = find_available_port_with_lock(
min_port=30000,
max_port=30010, # Small range to force conflicts
max_attempts=5,
bind_address='0.0.0.0',
lock_timeout=2.0,
)
if result:
port, lock = result
allocated_ports.append(port)
port_locks.append(lock)
# Simulate some work time
time.sleep(0.1)
return port
return None
# Run multiple threads concurrently
num_workers = 8
with ThreadPoolExecutor(max_workers=num_workers) as executor:
futures = [executor.submit(allocate_port) for _ in range(num_workers)]
results = [future.result() for future in as_completed(futures)]
# Filter out None results
successful_ports = [port for port in results if port is not None]
# Verify no duplicate ports were allocated
assert len(successful_ports) == len(set(successful_ports)), (
f'Duplicate ports allocated: {successful_ports}'
)
# Clean up locks
for lock in port_locks:
if lock:
lock.release()
print(
f'Successfully allocated {len(successful_ports)} unique ports: {successful_ports}'
)
def test_port_lock_basic_functionality(self):
"""Test basic port lock functionality."""
port = 30001
# Test acquiring and releasing a lock
lock1 = PortLock(port)
assert lock1.acquire(timeout=1.0)
assert lock1.is_locked
# Test that another lock cannot acquire the same port
lock2 = PortLock(port)
assert not lock2.acquire(timeout=0.1)
assert not lock2.is_locked
# Release first lock
lock1.release()
assert not lock1.is_locked
# Now second lock should be able to acquire
assert lock2.acquire(timeout=1.0)
assert lock2.is_locked
lock2.release()
def test_port_lock_context_manager(self):
"""Test port lock context manager functionality."""
port = 30002
# Test successful context manager usage
with PortLock(port) as lock:
assert lock.is_locked
# Test that another lock cannot acquire while in context
lock2 = PortLock(port)
assert not lock2.acquire(timeout=0.1)
# After context, lock should be released
assert not lock.is_locked
# Now another lock should be able to acquire
lock3 = PortLock(port)
assert lock3.acquire(timeout=1.0)
lock3.release()
def test_concurrent_port_allocation_stress_test(self):
"""Stress test concurrent port allocation."""
allocated_ports = []
port_locks = []
errors = []
def worker_allocate_port(worker_id):
"""Worker function that allocates a port."""
try:
result = find_available_port_with_lock(
min_port=31000,
max_port=31020, # Small range to force contention
max_attempts=10,
bind_address='0.0.0.0',
lock_timeout=3.0,
)
if result:
port, lock = result
allocated_ports.append((worker_id, port))
port_locks.append(lock)
# Simulate work
time.sleep(0.05)
return port
else:
errors.append(f'Worker {worker_id}: No port available')
return None
except Exception as e:
errors.append(f'Worker {worker_id}: {str(e)}')
return None
# Run many workers concurrently
num_workers = 15
with ThreadPoolExecutor(max_workers=num_workers) as executor:
futures = {
executor.submit(worker_allocate_port, i): i for i in range(num_workers)
}
results = {}
for future in as_completed(futures):
worker_id = futures[future]
try:
result = future.result()
results[worker_id] = result
except Exception as e:
errors.append(f'Worker {worker_id} exception: {str(e)}')
# Analyze results
successful_allocations = [
(wid, port) for wid, port in allocated_ports if port is not None
]
allocated_port_numbers = [port for _, port in successful_allocations]
print(f'Successful allocations: {len(successful_allocations)}')
print(f'Allocated ports: {allocated_port_numbers}')
print(f'Errors: {len(errors)}')
if errors:
print(f'Error details: {errors[:5]}') # Show first 5 errors
# Verify no duplicate ports
unique_ports = set(allocated_port_numbers)
assert len(allocated_port_numbers) == len(unique_ports), (
f'Duplicate ports found: {allocated_port_numbers}'
)
# Clean up locks
for lock in port_locks:
if lock:
lock.release()
def test_port_allocation_without_locking_shows_race_condition(self):
"""Test that demonstrates race condition without locking."""
from openhands.runtime.utils import find_available_tcp_port
allocated_ports = []
def allocate_port_without_lock():
"""Simulate port allocation without locking (old method)."""
# This simulates the old behavior that had race conditions
port = find_available_tcp_port(32000, 32010)
allocated_ports.append(port)
# Small delay to increase chance of race condition
time.sleep(0.01)
return port
# Run multiple threads concurrently
num_workers = 10
with ThreadPoolExecutor(max_workers=num_workers) as executor:
futures = [
executor.submit(allocate_port_without_lock) for _ in range(num_workers)
]
results = [future.result() for future in as_completed(futures)]
# Check if we got duplicate ports (race condition)
unique_ports = set(results)
duplicates_found = len(results) != len(unique_ports)
print(
f'Without locking - Total ports: {len(results)}, Unique: {len(unique_ports)}'
)
print(f'Ports allocated: {results}')
print(f'Race condition detected: {duplicates_found}')
# This test demonstrates the problem exists without locking
# In a real race condition scenario, we might get duplicates
# But since the race window is small, we'll just verify the test runs
assert len(results) == num_workers
if __name__ == '__main__':
test = TestPortLockingFix()
test.test_port_lock_prevents_duplicate_allocation()
test.test_port_lock_basic_functionality()
test.test_port_lock_context_manager()
test.test_concurrent_port_allocation_stress_test()
test.test_port_allocation_without_locking_shows_race_condition()
print('All tests passed!')

View File

@@ -1,15 +1,8 @@
"""Tests for system stats utilities."""
import time
from unittest.mock import patch
import psutil
from openhands.runtime.utils.system_stats import (
get_system_info,
get_system_stats,
update_last_execution_time,
)
from openhands.runtime.utils.system_stats import get_system_stats
def test_get_system_stats():
@@ -65,96 +58,3 @@ def test_get_system_stats_stability():
stats = get_system_stats()
assert isinstance(stats, dict)
assert stats['cpu_percent'] >= 0
def test_get_system_info():
"""Test that get_system_info returns valid system information."""
with patch(
'openhands.runtime.utils.system_stats.get_system_stats'
) as mock_get_stats:
mock_get_stats.return_value = {'cpu_percent': 10.0}
info = get_system_info()
# Test structure
assert isinstance(info, dict)
assert set(info.keys()) == {'uptime', 'idle_time', 'resources'}
# Test values
assert isinstance(info['uptime'], float)
assert isinstance(info['idle_time'], float)
assert info['uptime'] > 0
assert info['idle_time'] >= 0
assert info['resources'] == {'cpu_percent': 10.0}
# Verify get_system_stats was called
mock_get_stats.assert_called_once()
def test_update_last_execution_time():
"""Test that update_last_execution_time updates the last execution time."""
# Get initial system info
initial_info = get_system_info()
initial_idle_time = initial_info['idle_time']
# Wait a bit to ensure time difference
time.sleep(0.1)
# Update last execution time
update_last_execution_time()
# Get updated system info
updated_info = get_system_info()
updated_idle_time = updated_info['idle_time']
# The idle time should be reset (close to zero)
assert updated_idle_time < initial_idle_time
assert updated_idle_time < 0.1 # Should be very small
def test_idle_time_increases_without_updates():
"""Test that idle_time increases when no updates are made."""
# Update last execution time to reset idle time
update_last_execution_time()
# Get initial system info
initial_info = get_system_info()
initial_idle_time = initial_info['idle_time']
# Wait a bit
time.sleep(0.2)
# Get updated system info without calling update_last_execution_time
updated_info = get_system_info()
updated_idle_time = updated_info['idle_time']
# The idle time should have increased
assert updated_idle_time > initial_idle_time
assert updated_idle_time >= 0.2 # Should be at least the sleep time
@patch('time.time')
def test_idle_time_calculation(mock_time):
"""Test that idle_time is calculated correctly."""
# Mock time.time() to return controlled values
mock_time.side_effect = [
100.0, # Initial _start_time
100.0, # Initial _last_execution_time
110.0, # Current time in get_system_info
]
# Import the module again to reset the global variables with our mocked time
import importlib
import openhands.runtime.utils.system_stats
importlib.reload(openhands.runtime.utils.system_stats)
# Get system info
from openhands.runtime.utils.system_stats import get_system_info
info = get_system_info()
# Verify idle_time calculation
assert info['uptime'] == 10.0 # 110 - 100
assert info['idle_time'] == 10.0 # 110 - 100

View File

@@ -450,7 +450,7 @@ async def test_bitbucket_sort_parameter_mapping():
]
# Call get_repositories with sort='pushed'
await service.get_repositories('pushed', AppMode.SAAS)
await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify that the second call used 'updated_on' instead of 'pushed'
assert mock_request.call_count == 2
@@ -520,7 +520,7 @@ async def test_bitbucket_pagination():
]
# Call get_repositories
repositories = await service.get_repositories('pushed', AppMode.SAAS)
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify that all three requests were made (workspaces + 2 pages of repos)
assert mock_request.call_count == 3
@@ -619,7 +619,7 @@ async def test_bitbucket_get_repositories_with_user_owner_type():
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
mock_fetch.side_effect = [mock_workspaces, mock_repos]
repositories = await service.get_repositories('pushed', AppMode.SAAS)
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -658,7 +658,7 @@ async def test_bitbucket_get_repositories_with_organization_owner_type():
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
mock_fetch.side_effect = [mock_workspaces, mock_repos]
repositories = await service.get_repositories('pushed', AppMode.SAAS)
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -706,7 +706,7 @@ async def test_bitbucket_get_repositories_mixed_owner_types():
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
mock_fetch.side_effect = [mock_workspaces, mock_user_repos, mock_org_repos]
repositories = await service.get_repositories('pushed', AppMode.SAAS)
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify we got repositories from both workspaces
assert len(repositories) == 2
@@ -746,7 +746,7 @@ async def test_bitbucket_get_repositories_owner_type_fallback():
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
mock_fetch.side_effect = [mock_workspaces, mock_repos]
repositories = await service.get_repositories('pushed', AppMode.SAAS)
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify all repositories default to USER owner_type for private workspaces
for repo in repositories:

View File

@@ -4,16 +4,12 @@ import tempfile
from pathlib import Path
from unittest.mock import patch
from openhands.cli.main import alias_setup_declined as main_alias_setup_declined
from openhands.cli.main import aliases_exist_in_shell_config, run_alias_setup_flow
from openhands.cli.shell_config import (
ShellConfigManager,
add_aliases_to_shell_config,
alias_setup_declined,
aliases_exist_in_shell_config,
get_shell_config_path,
mark_alias_setup_declined,
)
from openhands.core.config import OpenHandsConfig
def test_get_shell_config_path_no_files_fallback():
@@ -248,121 +244,3 @@ def test_shell_config_manager_template_rendering():
assert 'test-command' in content
assert 'alias openhands="test-command"' in content
assert 'alias oh="test-command"' in content
def test_alias_setup_declined_false():
"""Test alias setup declined check when marker file doesn't exist."""
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
assert alias_setup_declined() is False
def test_alias_setup_declined_true():
"""Test alias setup declined check when marker file exists."""
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
# Create the marker file
mark_alias_setup_declined()
assert alias_setup_declined() is True
def test_mark_alias_setup_declined():
"""Test marking alias setup as declined creates the marker file."""
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
# Initially should be False
assert alias_setup_declined() is False
# Mark as declined
mark_alias_setup_declined()
# Should now be True
assert alias_setup_declined() is True
# Verify the file exists
marker_file = Path(temp_dir) / '.openhands' / '.cli_alias_setup_declined'
assert marker_file.exists()
def test_alias_setup_declined_persisted():
"""Test that when user declines alias setup, their choice is persisted."""
config = OpenHandsConfig()
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
with patch(
'openhands.cli.shell_config.aliases_exist_in_shell_config',
return_value=False,
):
with patch(
'openhands.cli.main.cli_confirm', return_value=1
): # User chooses "No"
with patch('prompt_toolkit.print_formatted_text'):
# Initially, user hasn't declined
assert not alias_setup_declined()
# Run the alias setup flow
run_alias_setup_flow(config)
# After declining, the marker should be set
assert alias_setup_declined()
def test_alias_setup_skipped_when_previously_declined():
"""Test that alias setup is skipped when user has previously declined."""
OpenHandsConfig()
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
# Mark that user has previously declined
mark_alias_setup_declined()
assert alias_setup_declined()
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
with patch(
'openhands.cli.shell_config.aliases_exist_in_shell_config',
return_value=False,
):
with patch('openhands.cli.main.cli_confirm'):
with patch('prompt_toolkit.print_formatted_text'):
# This should not show the setup flow since user previously declined
# We test this by checking the main logic conditions
should_show = (
not aliases_exist_in_shell_config()
and not main_alias_setup_declined()
)
assert not should_show, (
'Alias setup should be skipped when user previously declined'
)
def test_alias_setup_accepted_does_not_set_declined_flag():
"""Test that when user accepts alias setup, no declined marker is created."""
config = OpenHandsConfig()
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
with patch(
'openhands.cli.shell_config.aliases_exist_in_shell_config',
return_value=False,
):
with patch(
'openhands.cli.main.cli_confirm', return_value=0
): # User chooses "Yes"
with patch(
'openhands.cli.shell_config.add_aliases_to_shell_config',
return_value=True,
):
with patch('prompt_toolkit.print_formatted_text'):
# Initially, user hasn't declined
assert not alias_setup_declined()
# Run the alias setup flow
run_alias_setup_flow(config)
# After accepting, the declined marker should still be False
assert not alias_setup_declined()

View File

@@ -179,7 +179,6 @@ async def test_search_conversations():
selected_repository='foobar',
num_connections=0,
url=None,
pr_number=[], # Default empty list for pr_number
)
]
)
@@ -639,7 +638,6 @@ async def test_get_conversation():
selected_repository='foobar',
num_connections=0,
url=None,
pr_number=[], # Default empty list for pr_number
)
assert conversation == expected
@@ -1200,365 +1198,3 @@ async def test_new_conversation_with_create_microagent_minimal(provider_handler_
assert (
call_args['git_provider'] is None
) # Should remain None since not set in create_microagent
@pytest.mark.asyncio
async def test_search_conversations_with_pr_number():
"""Test searching conversations includes pr_number field in response."""
with _patch_store():
with patch(
'openhands.server.routes.manage_conversations.config'
) as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(
results=[
ConversationMetadata(
conversation_id='conversation_with_pr',
title='Conversation with PR',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[123, 456], # Multiple PR numbers
)
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify the result includes pr_number field
assert len(result_set.results) == 1
conversation_info = result_set.results[0]
assert conversation_info.pr_number == [123, 456]
assert conversation_info.conversation_id == 'conversation_with_pr'
assert conversation_info.title == 'Conversation with PR'
@pytest.mark.asyncio
async def test_search_conversations_with_empty_pr_number():
"""Test searching conversations with empty pr_number field."""
with _patch_store():
with patch(
'openhands.server.routes.manage_conversations.config'
) as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(
results=[
ConversationMetadata(
conversation_id='conversation_no_pr',
title='Conversation without PR',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[], # Empty PR numbers list
)
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify the result includes empty pr_number field
assert len(result_set.results) == 1
conversation_info = result_set.results[0]
assert conversation_info.pr_number == []
assert conversation_info.conversation_id == 'conversation_no_pr'
assert conversation_info.title == 'Conversation without PR'
@pytest.mark.asyncio
async def test_search_conversations_with_single_pr_number():
"""Test searching conversations with single PR number."""
with _patch_store():
with patch(
'openhands.server.routes.manage_conversations.config'
) as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(
results=[
ConversationMetadata(
conversation_id='conversation_single_pr',
title='Conversation with Single PR',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[789], # Single PR number
)
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify the result includes single pr_number
assert len(result_set.results) == 1
conversation_info = result_set.results[0]
assert conversation_info.pr_number == [789]
assert conversation_info.conversation_id == 'conversation_single_pr'
assert conversation_info.title == 'Conversation with Single PR'
@pytest.mark.asyncio
async def test_get_conversation_with_pr_number():
"""Test getting a single conversation includes pr_number field."""
with _patch_store():
# Mock the conversation store
mock_store = MagicMock()
mock_store.get_metadata = AsyncMock(
return_value=ConversationMetadata(
conversation_id='conversation_with_pr',
title='Conversation with PR',
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
selected_repository='test/repo',
user_id='12345',
pr_number=[123, 456, 789], # Multiple PR numbers
)
)
# Mock the conversation manager
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
mock_manager.is_agent_loop_running = AsyncMock(return_value=False)
mock_manager.get_connections = AsyncMock(return_value={})
mock_manager.get_agent_loop_info = AsyncMock(return_value=[])
conversation = await get_conversation(
'conversation_with_pr', conversation_store=mock_store
)
expected = ConversationInfo(
conversation_id='conversation_with_pr',
title='Conversation with PR',
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
status=ConversationStatus.STOPPED,
selected_repository='test/repo',
num_connections=0,
url=None,
pr_number=[123, 456, 789], # Should include PR numbers
)
assert conversation == expected
@pytest.mark.asyncio
async def test_search_conversations_multiple_with_pr_numbers():
"""Test searching conversations with multiple conversations having different PR numbers."""
with _patch_store():
with patch(
'openhands.server.routes.manage_conversations.config'
) as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(
results=[
ConversationMetadata(
conversation_id='conversation_1',
title='Conversation 1',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[100, 200], # Multiple PR numbers
),
ConversationMetadata(
conversation_id='conversation_2',
title='Conversation 2',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[], # Empty PR numbers
),
ConversationMetadata(
conversation_id='conversation_3',
title='Conversation 3',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='12345',
pr_number=[300], # Single PR number
),
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify all results include pr_number field
assert len(result_set.results) == 3
# Check first conversation
assert result_set.results[0].conversation_id == 'conversation_1'
assert result_set.results[0].pr_number == [100, 200]
# Check second conversation
assert result_set.results[1].conversation_id == 'conversation_2'
assert result_set.results[1].pr_number == []
# Check third conversation
assert result_set.results[2].conversation_id == 'conversation_3'
assert result_set.results[2].pr_number == [300]

View File

@@ -102,7 +102,7 @@ def mock_repo_microagent():
]
),
),
source='.openhands/microagents/test_repo_agent.md',
source='test_source',
type=MicroagentType.REPO_KNOWLEDGE,
)
@@ -128,7 +128,7 @@ def mock_knowledge_microagent():
]
),
),
source='.openhands/microagents/test_knowledge_agent.md',
source='test_source',
type=MicroagentType.KNOWLEDGE,
triggers=['test', 'knowledge', 'search'],
)
@@ -283,72 +283,7 @@ class TestGetRepositoryMicroagents:
mock_result.stderr = ''
mock_subprocess_run.return_value = mock_result
# Create mock microagents with proper absolute paths
repo_agent_with_path = RepoMicroagent(
name='test_repo_agent',
content='This is a test repository microagent for testing purposes.',
metadata=MicroagentMetadata(
name='test_repo_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[
InputMetadata(
name='query',
type='str',
description='Search query for the repository',
)
],
mcp_tools=MCPConfig(
stdio_servers=[
MCPStdioServerConfig(name='git', command='git'),
MCPStdioServerConfig(name='file_editor', command='editor'),
]
),
),
source=str(
Path(temp_microagents_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'test_repo_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
knowledge_agent_with_path = KnowledgeMicroagent(
name='test_knowledge_agent',
content='This is a test knowledge microagent for testing purposes.',
metadata=MicroagentMetadata(
name='test_knowledge_agent',
type=MicroagentType.KNOWLEDGE,
inputs=[
InputMetadata(
name='topic', type='str', description='Topic to search for'
)
],
mcp_tools=MCPConfig(
stdio_servers=[
MCPStdioServerConfig(name='search', command='search'),
MCPStdioServerConfig(name='fetch', command='fetch'),
]
),
),
source=str(
Path(temp_microagents_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'test_knowledge_agent.md'
),
type=MicroagentType.KNOWLEDGE,
triggers=['test', 'knowledge', 'search'],
)
mock_microagents_data_with_paths = (
{'test_repo_agent': repo_agent_with_path},
{'test_knowledge_agent': knowledge_agent_with_path},
)
mock_load_microagents.return_value = mock_microagents_data_with_paths
mock_load_microagents.return_value = mock_microagents_data
mock_mkdtemp.return_value = temp_microagents_dir
# Execute test
@@ -373,8 +308,6 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in repo_agent
assert 'git_provider' in repo_agent
assert repo_agent['git_provider'] == 'github'
assert 'path' in repo_agent
assert repo_agent['path'] == '.openhands/microagents/test_repo_agent.md'
# Check knowledge microagent
knowledge_agent = next(m for m in data if m['name'] == 'test_knowledge_agent')
@@ -390,10 +323,6 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in knowledge_agent
assert 'git_provider' in knowledge_agent
assert knowledge_agent['git_provider'] == 'github'
assert 'path' in knowledge_agent
assert (
knowledge_agent['path'] == '.openhands/microagents/test_knowledge_agent.md'
)
@pytest.mark.asyncio
@patch('openhands.server.routes.git.ProviderHandler')
@@ -626,38 +555,8 @@ class TestGetRepositoryMicroagents:
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create mock microagents with proper absolute paths
repo_agent_with_path = RepoMicroagent(
name='test_repo_agent',
content='This is a test repository microagent for testing purposes.',
metadata=MicroagentMetadata(
name='test_repo_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[
InputMetadata(
name='query',
type='str',
description='Search query for the repository',
)
],
mcp_tools=MCPConfig(
stdio_servers=[
MCPStdioServerConfig(name='git', command='git'),
MCPStdioServerConfig(name='file_editor', command='editor'),
]
),
),
source=str(
Path(temp_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'test_repo_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
mock_repo_agents = {'test_repo_agent': repo_agent_with_path}
# Mock load_microagents_from_dir
mock_repo_agents = {'test_repo_agent': mock_repo_microagent}
mock_knowledge_agents = {}
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
mock_mkdtemp.return_value = temp_dir
@@ -675,8 +574,6 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in data[0]
assert 'git_provider' in data[0]
assert data[0]['git_provider'] == 'github'
assert 'path' in data[0]
assert data[0]['path'] == '.openhands/microagents/test_repo_agent.md'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -737,38 +634,8 @@ class TestGetRepositoryMicroagents:
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create mock microagents with proper absolute paths
repo_agent_with_path = RepoMicroagent(
name='test_repo_agent',
content='This is a test repository microagent for testing purposes.',
metadata=MicroagentMetadata(
name='test_repo_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[
InputMetadata(
name='query',
type='str',
description='Search query for the repository',
)
],
mcp_tools=MCPConfig(
stdio_servers=[
MCPStdioServerConfig(name='git', command='git'),
MCPStdioServerConfig(name='file_editor', command='editor'),
]
),
),
source=str(
Path(temp_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'test_repo_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
mock_repo_agents = {'test_repo_agent': repo_agent_with_path}
# Mock load_microagents_from_dir
mock_repo_agents = {'test_repo_agent': mock_repo_microagent}
mock_knowledge_agents = {}
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
mock_mkdtemp.return_value = temp_dir
@@ -785,8 +652,6 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in data[0]
assert 'git_provider' in data[0]
assert data[0]['git_provider'] == 'github'
assert 'path' in data[0]
assert data[0]['path'] == '.openhands/microagents/test_repo_agent.md'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -883,6 +748,20 @@ class TestGetRepositoryMicroagents:
lambda: mock_provider_tokens
)
# Create microagent without MCP tools
repo_microagent = RepoMicroagent(
name='simple_agent',
content='Simple agent without MCP tools',
metadata=MicroagentMetadata(
name='simple_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[],
mcp_tools=None,
),
source='test_source',
type=MicroagentType.REPO_KNOWLEDGE,
)
mock_provider_handler = MagicMock()
mock_repository = Repository(
id='123456',
@@ -910,26 +789,6 @@ class TestGetRepositoryMicroagents:
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create microagent without MCP tools
repo_microagent = RepoMicroagent(
name='simple_agent',
content='Simple agent without MCP tools',
metadata=MicroagentMetadata(
name='simple_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[],
mcp_tools=None,
),
source=str(
Path(temp_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'simple_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
# Mock load_microagents_from_dir
mock_repo_agents = {'simple_agent': repo_microagent}
mock_knowledge_agents = {}
@@ -949,225 +808,5 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in data[0]
assert 'git_provider' in data[0]
assert data[0]['git_provider'] == 'github'
assert 'path' in data[0]
assert data[0]['path'] == '.openhands/microagents/simple_agent.md'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.mark.asyncio
@patch(
'openhands.server.routes.git._get_file_creation_time',
return_value=datetime.now(),
)
@patch('openhands.server.routes.git.tempfile.mkdtemp')
@patch('openhands.server.routes.git.load_microagents_from_dir')
@patch('openhands.server.routes.git.subprocess.run')
@patch('openhands.server.routes.git.ProviderHandler')
async def test_get_microagents_path_field_variations(
self,
mock_provider_handler_class,
mock_subprocess_run,
mock_load_microagents,
mock_mkdtemp,
mock_get_file_creation_time,
test_client,
mock_provider_tokens,
):
"""Test path field with different microagent file locations and structures."""
# Setup mocks
test_client.app.dependency_overrides[get_provider_tokens] = (
lambda: mock_provider_tokens
)
mock_provider_handler = MagicMock()
mock_repository = Repository(
id='123456',
full_name='test/repo',
git_provider=ProviderType.GITHUB,
is_public=True,
stargazers_count=100,
)
mock_provider_handler.verify_repo_provider = AsyncMock(
return_value=mock_repository
)
mock_provider_handler.get_authenticated_git_url = AsyncMock(
return_value='https://ghp_test_token@github.com/test/repo.git'
)
mock_provider_handler_class.return_value = mock_provider_handler
# Mock subprocess.run for successful clone
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stderr = ''
mock_subprocess_run.return_value = mock_result
# Create temporary directory with microagents
temp_dir = tempfile.mkdtemp()
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create microagents with different source paths
repo_microagent_deep = RepoMicroagent(
name='deep_agent',
content='Agent in nested directory',
metadata=MicroagentMetadata(
name='deep_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[],
mcp_tools=None,
),
source=str(
Path(temp_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'nested'
/ 'deep_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
knowledge_microagent_root = KnowledgeMicroagent(
name='root_agent',
content='Agent in root microagents directory',
metadata=MicroagentMetadata(
name='root_agent',
type=MicroagentType.KNOWLEDGE,
inputs=[],
mcp_tools=None,
),
source=str(
Path(temp_dir) / 'repo' / '.openhands' / 'microagents' / 'root_agent.md'
),
type=MicroagentType.KNOWLEDGE,
triggers=[],
)
# Mock load_microagents_from_dir
mock_repo_agents = {'deep_agent': repo_microagent_deep}
mock_knowledge_agents = {'root_agent': knowledge_microagent_root}
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
mock_mkdtemp.return_value = temp_dir
try:
# Execute test
response = test_client.get('/api/user/repository/test/repo/microagents')
# Assertions
assert response.status_code == 200
data = response.json()
assert len(data) == 2
# Check repo microagent with nested path
repo_agent = next(m for m in data if m['name'] == 'deep_agent')
assert repo_agent['type'] == 'repo'
assert 'path' in repo_agent
assert repo_agent['path'] == '.openhands/microagents/nested/deep_agent.md'
# Check knowledge microagent with root path
knowledge_agent = next(m for m in data if m['name'] == 'root_agent')
assert knowledge_agent['type'] == 'knowledge'
assert 'path' in knowledge_agent
assert knowledge_agent['path'] == '.openhands/microagents/root_agent.md'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.mark.asyncio
@patch(
'openhands.server.routes.git._get_file_creation_time',
return_value=datetime.now(),
)
@patch('openhands.server.routes.git.tempfile.mkdtemp')
@patch('openhands.server.routes.git.load_microagents_from_dir')
@patch('openhands.server.routes.git.subprocess.run')
@patch('openhands.server.routes.git.ProviderHandler')
async def test_get_microagents_path_field_gitlab_structure(
self,
mock_provider_handler_class,
mock_subprocess_run,
mock_load_microagents,
mock_mkdtemp,
mock_get_file_creation_time,
test_client,
mock_provider_tokens,
):
"""Test path field with GitLab repository structure (openhands-config)."""
# Setup mocks with GitLab provider
provider_tokens = MappingProxyType(
{
ProviderType.GITLAB: ProviderToken(
token=SecretStr('glpat_test_token'), host='gitlab.com'
)
}
)
test_client.app.dependency_overrides[get_provider_tokens] = (
lambda: provider_tokens
)
mock_provider_handler = MagicMock()
mock_repository = Repository(
id='123456',
full_name='test/openhands-config',
git_provider=ProviderType.GITLAB,
is_public=True,
stargazers_count=100,
)
mock_provider_handler.verify_repo_provider = AsyncMock(
return_value=mock_repository
)
mock_provider_handler.get_authenticated_git_url = AsyncMock(
return_value='https://glpat_test_token@gitlab.com/test/openhands-config.git'
)
mock_provider_handler_class.return_value = mock_provider_handler
# Mock subprocess.run for successful clone
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stderr = ''
mock_subprocess_run.return_value = mock_result
# Create temporary directory with GitLab structure
temp_dir = tempfile.mkdtemp()
microagents_dir = Path(temp_dir) / 'repo' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create microagent for GitLab structure
repo_microagent = RepoMicroagent(
name='gitlab_agent',
content='Agent in GitLab repository',
metadata=MicroagentMetadata(
name='gitlab_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[],
mcp_tools=None,
),
source=str(Path(temp_dir) / 'repo' / 'microagents' / 'gitlab_agent.md'),
type=MicroagentType.REPO_KNOWLEDGE,
)
# Mock load_microagents_from_dir
mock_repo_agents = {'gitlab_agent': repo_microagent}
mock_knowledge_agents = {}
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
mock_mkdtemp.return_value = temp_dir
try:
# Execute test
response = test_client.get(
'/api/user/repository/test/openhands-config/microagents'
)
# Assertions
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]['name'] == 'gitlab_agent'
assert data[0]['type'] == 'repo'
assert 'path' in data[0]
assert data[0]['path'] == 'microagents/gitlab_agent.md'
assert 'git_provider' in data[0]
assert data[0]['git_provider'] == 'gitlab'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)

View File

@@ -112,9 +112,9 @@ async def test_github_get_repositories_with_user_owner_type():
with (
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
patch.object(service, 'get_installation_ids', return_value=[123]),
patch.object(service, 'get_installations', return_value=[123]),
):
repositories = await service.get_repositories('pushed', AppMode.SAAS)
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -151,9 +151,9 @@ async def test_github_get_repositories_with_organization_owner_type():
with (
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
patch.object(service, 'get_installation_ids', return_value=[123]),
patch.object(service, 'get_installations', return_value=[123]),
):
repositories = await service.get_repositories('pushed', AppMode.SAAS)
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -190,9 +190,9 @@ async def test_github_get_repositories_mixed_owner_types():
with (
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
patch.object(service, 'get_installation_ids', return_value=[123]),
patch.object(service, 'get_installations', return_value=[123]),
):
repositories = await service.get_repositories('pushed', AppMode.SAAS)
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -237,9 +237,9 @@ async def test_github_get_repositories_owner_type_fallback():
with (
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
patch.object(service, 'get_installation_ids', return_value=[123]),
patch.object(service, 'get_installations', return_value=[123]),
):
repositories = await service.get_repositories('pushed', AppMode.SAAS)
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify all repositories default to USER owner_type
for repo in repositories:

View File

@@ -37,7 +37,7 @@ async def test_gitlab_get_repositories_with_user_owner_type():
# Mock the pagination response
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
repositories = await service.get_repositories('pushed', AppMode.SAAS)
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -76,7 +76,7 @@ async def test_gitlab_get_repositories_with_organization_owner_type():
# Mock the pagination response
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
repositories = await service.get_repositories('pushed', AppMode.SAAS)
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -115,7 +115,7 @@ async def test_gitlab_get_repositories_mixed_owner_types():
# Mock the pagination response
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
repositories = await service.get_repositories('pushed', AppMode.SAAS)
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -162,7 +162,7 @@ async def test_gitlab_get_repositories_owner_type_fallback():
# Mock the pagination response
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
repositories = await service.get_repositories('pushed', AppMode.SAAS)
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
# Verify all repositories default to USER owner_type
for repo in repositories:

View File

@@ -101,7 +101,7 @@ def test_prep_build_folder(temp_dir):
def test_get_hash_for_lock_files():
with patch('builtins.open', mock_open(read_data='mock-data'.encode())):
hash = get_hash_for_lock_files('some_base_image', enable_browser=True)
hash = get_hash_for_lock_files('some_base_image')
# Since we mocked open to always return "mock_data", the hash is the result
# of hashing the name of the base image followed by "mock-data" twice
md5 = hashlib.md5()
@@ -111,31 +111,6 @@ def test_get_hash_for_lock_files():
assert hash == truncate_hash(md5.hexdigest())
def test_get_hash_for_lock_files_different_enable_browser():
with patch('builtins.open', mock_open(read_data='mock-data'.encode())):
hash_true = get_hash_for_lock_files('some_base_image', enable_browser=True)
hash_false = get_hash_for_lock_files('some_base_image', enable_browser=False)
# Hash with enable_browser=True should not include the enable_browser value
md5_true = hashlib.md5()
md5_true.update('some_base_image'.encode())
for _ in range(2):
md5_true.update('mock-data'.encode())
expected_hash_true = truncate_hash(md5_true.hexdigest())
# Hash with enable_browser=False should include the enable_browser value
md5_false = hashlib.md5()
md5_false.update('some_base_image'.encode())
md5_false.update('False'.encode()) # enable_browser=False is included
for _ in range(2):
md5_false.update('mock-data'.encode())
expected_hash_false = truncate_hash(md5_false.hexdigest())
assert hash_true == expected_hash_true
assert hash_false == expected_hash_false
assert hash_true != hash_false # They should be different
def test_get_hash_for_source_files():
dirhash_mock = MagicMock()
dirhash_mock.return_value = '1f69bd20d68d9e3874d5bf7f7459709b'
@@ -272,7 +247,7 @@ def test_build_runtime_image_from_scratch():
== f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
)
mock_prep_build_folder.assert_called_once_with(
ANY, base_image, BuildFromImageType.SCRATCH, None, True
ANY, base_image, BuildFromImageType.SCRATCH, None
)
@@ -367,7 +342,6 @@ def test_build_runtime_image_exact_hash_not_exist_and_lock_exist():
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag',
BuildFromImageType.LOCK,
None,
True,
)
@@ -427,7 +401,6 @@ def test_build_runtime_image_exact_hash_not_exist_and_lock_not_exist_and_version
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-versioned-tag',
BuildFromImageType.VERSIONED,
None,
True,
)

Some files were not shown because too many files have changed in this diff Show More