Compare commits

..

2 Commits

Author SHA1 Message Date
Graham Neubig
2e2ed78ee2 Merge branch 'main' into feature/org-microagents-blocking-timeout 2025-08-05 16:20:08 -04:00
openhands
5ac1fd9077 Make organization microagents clone operation blocking with 60-second timeout 2025-06-23 22:32:06 +00:00
86 changed files with 1623 additions and 4672 deletions

View File

@@ -58,34 +58,34 @@ RUN sed -i 's/^UID_MIN.*/UID_MIN 499/' /etc/login.defs
# Default is 60000, but we've seen up to 200000
RUN sed -i 's/^UID_MAX.*/UID_MAX 1000000/' /etc/login.defs
RUN groupadd --gid $OPENHANDS_USER_ID openhands
RUN groupadd --gid $OPENHANDS_USER_ID app
RUN useradd -l -m -u $OPENHANDS_USER_ID --gid $OPENHANDS_USER_ID -s /bin/bash openhands && \
usermod -aG openhands openhands && \
usermod -aG app openhands && \
usermod -aG sudo openhands && \
echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
RUN chown -R openhands:openhands /app && chmod -R 770 /app
RUN sudo chown -R openhands:openhands $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
RUN chown -R openhands:app /app && chmod -R 770 /app
RUN sudo chown -R openhands:app $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
USER openhands
ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH" \
PYTHONPATH='/app'
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --chown=openhands:app --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --chown=openhands:openhands --chmod=770 ./microagents ./microagents
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
COPY --chown=openhands:app --chmod=770 ./microagents ./microagents
COPY --chown=openhands:app --chmod=770 ./openhands ./openhands
COPY --chown=openhands:app --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
COPY --chown=openhands:app pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
# This is run as "openhands" user, and will create __pycache__ with openhands:openhands ownership
RUN python openhands/core/download.py # No-op to download assets
# Add this line to set group ownership of all files/directories not already in "app" group
# openhands:openhands -> openhands:openhands
RUN find /app \! -group openhands -exec chgrp openhands {} +
# openhands:openhands -> openhands:app
RUN find /app \! -group app -exec chgrp app {} +
COPY --chown=openhands:openhands --chmod=770 --from=frontend-builder /app/build ./frontend/build
COPY --chown=openhands:openhands --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
COPY --chown=openhands:app --chmod=770 --from=frontend-builder /app/build ./frontend/build
COPY --chown=openhands:app --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
USER root

View File

@@ -71,7 +71,6 @@
{
"group": "Providers",
"pages": [
"usage/llms/openhands-llms",
"usage/llms/azure-llms",
"usage/llms/google-llms",
"usage/llms/groq",
@@ -79,6 +78,7 @@
"usage/llms/litellm-proxy",
"usage/llms/moonshot",
"usage/llms/openai-llms",
"usage/llms/openhands-llms",
"usage/llms/openrouter"
]
}

View File

@@ -100,16 +100,6 @@ OpenHands requires an API key to access most language models. Here's how to get
<AccordionGroup>
<Accordion title="OpenHands (Recommended)">
1. [Log in to OpenHands Cloud](https://app.all-hands.dev).
2. Go to the Settings page and navigate to the `API Keys` tab.
3. Copy your `LLM API Key`.
OpenHands provides access to state-of-the-art agentic coding models with competitive pricing. [Learn more about OpenHands LLM provider](/usage/llms/openhands-llms).
</Accordion>
<Accordion title="Anthropic (Claude)">
1. [Create an Anthropic account](https://console.anthropic.com/).

View File

@@ -85,36 +85,17 @@ describe("RepoConnector", () => {
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
// Mock the search function that's used by the dropdown
vi.spyOn(OpenHands, "searchGitRepositories").mockResolvedValue(
MOCK_RESPOSITORIES,
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
renderRepoConnector();
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("Github"));
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
// Then interact with the repository dropdown
const repoDropdown = await waitFor(() =>
screen.getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
await waitFor(() => {
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
expect(screen.getByText("All-Hands-AI/OpenHands")).toBeInTheDocument();
screen.getByText("rbren/polaris");
screen.getByText("All-Hands-AI/OpenHands");
});
});
@@ -123,47 +104,18 @@ describe("RepoConnector", () => {
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
renderRepoConnector();
const launchButton = await screen.findByTestId("repo-launch-button");
expect(launchButton).toBeDisabled();
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
]);
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoDropdown = await waitFor(() =>
screen.getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
await waitFor(() => {
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
});
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await userEvent.click(screen.getByText("rbren/polaris"));
// Wait for the branch to be auto-selected
await waitFor(() => {
expect(screen.getByText("main")).toBeInTheDocument();
});
expect(launchButton).toBeEnabled();
});
@@ -228,10 +180,7 @@ describe("RepoConnector", () => {
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
renderRepoConnector();
@@ -243,37 +192,14 @@ describe("RepoConnector", () => {
// repo not selected yet
expect(createConversationSpy).not.toHaveBeenCalled();
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
]);
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoDropdown = await waitFor(() =>
// select a repository from the dropdown
const dropdown = await waitFor(() =>
within(repoConnector).getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
await waitFor(() => {
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
});
await userEvent.click(screen.getByText("rbren/polaris"));
// Wait for the branch to be auto-selected
await waitFor(() => {
expect(screen.getByText("main")).toBeInTheDocument();
});
await userEvent.click(dropdown);
const repoOption = screen.getByText("rbren/polaris");
await userEvent.click(repoOption);
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
@@ -292,46 +218,17 @@ describe("RepoConnector", () => {
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
]);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
renderRepoConnector();
const launchButton = await screen.findByTestId("repo-launch-button");
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoDropdown = await waitFor(() =>
screen.getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
await waitFor(() => {
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
});
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await userEvent.click(screen.getByText("rbren/polaris"));
// Wait for the branch to be auto-selected
await waitFor(() => {
expect(screen.getByText("main")).toBeInTheDocument();
});
await userEvent.click(launchButton);
expect(launchButton).toBeDisabled();
expect(launchButton).toHaveTextContent(/Loading/i);

View File

@@ -12,8 +12,6 @@ const mockUseCreateConversation = vi.fn();
const mockUseIsCreatingConversation = vi.fn();
const mockUseTranslation = vi.fn();
const mockUseAuth = vi.fn();
const mockUseGitRepositories = vi.fn();
const mockUseUserProviders = vi.fn();
// Setup default mock returns
mockUseUserRepositories.mockReturnValue({
@@ -32,29 +30,6 @@ mockUseIsCreatingConversation.mockReturnValue(false);
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
// Default mock for useGitRepositories
mockUseGitRepositories.mockReturnValue({
data: { pages: [] },
isLoading: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
onLoadMore: vi.fn(),
});
vi.mock("react-i18next", () => ({
useTranslation: () => mockUseTranslation(),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => mockUseUserProviders(),
}));
mockUseUserProviders.mockReturnValue({
providers: ["github"],
});
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
@@ -96,10 +71,6 @@ vi.mock("react-router", async (importActual) => ({
useNavigate: vi.fn(),
}));
vi.mock("#/hooks/query/use-git-repositories", () => ({
useGitRepositories: () => mockUseGitRepositories(),
}));
const mockOnRepoSelection = vi.fn();
const renderForm = () =>
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
@@ -125,6 +96,34 @@ describe("RepositorySelectionForm", () => {
vi.clearAllMocks();
});
it("shows loading indicator when repositories are being fetched", () => {
const MOCK_REPOS: GitRepository[] = [
{
id: "1",
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: "2",
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
];
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
renderForm();
// Check if loading indicator is displayed
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
});
it("shows dropdown when repositories are loaded", async () => {
const MOCK_REPOS: GitRepository[] = [
{
@@ -140,30 +139,24 @@ describe("RepositorySelectionForm", () => {
is_public: true,
},
];
mockUseGitRepositories.mockReturnValue({
data: { pages: [{ data: MOCK_REPOS }] },
isLoading: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
onLoadMore: vi.fn(),
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
renderForm();
expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
});
it("shows error message when repository fetch fails", async () => {
mockUseGitRepositories.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
onLoadMore: vi.fn(),
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockRejectedValue(
new Error("Failed to load"),
);
renderForm();
@@ -201,45 +194,40 @@ describe("RepositorySelectionForm", () => {
];
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
mockUseGitRepositories.mockReturnValue({
data: { pages: [{ data: MOCK_REPOS }] },
isLoading: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
onLoadMore: vi.fn(),
});
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
providersAreSet: true,
user: {
id: 1,
login: "testuser",
avatar_url: "https://example.com/avatar.png",
name: "Test User",
email: "test@example.com",
company: "Test Company",
},
login: vi.fn(),
logout: vi.fn(),
});
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
renderForm();
const dropdown = await screen.findByTestId("repo-dropdown");
const input = dropdown.querySelector('input[type="text"]') as HTMLInputElement;
expect(input).toBeInTheDocument();
const input = await screen.findByTestId("repo-dropdown");
await userEvent.click(input);
for (const repo of MOCK_REPOS) {
expect(screen.getByText(repo.full_name)).toBeInTheDocument();
}
expect(
screen.queryByText(MOCK_SEARCH_REPOS[0].full_name),
).not.toBeInTheDocument();
expect(searchGitReposSpy).not.toHaveBeenCalled();
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
"kubernetes/kubernetes",
3,
);
expect(
screen.getByText(MOCK_SEARCH_REPOS[0].full_name),
).toBeInTheDocument();
for (const repo of MOCK_REPOS) {
expect(screen.queryByText(repo.full_name)).not.toBeInTheDocument();
}
});
it("should call onRepoSelection when a searched repository is selected", async () => {
@@ -255,26 +243,20 @@ describe("RepositorySelectionForm", () => {
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
mockUseGitRepositories.mockReturnValue({
data: { pages: [{ data: MOCK_SEARCH_REPOS }] },
isLoading: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
onLoadMore: vi.fn(),
});
renderForm();
const dropdown = await screen.findByTestId("repo-dropdown");
const input = dropdown.querySelector('input[type="text"]') as HTMLInputElement;
expect(input).toBeInTheDocument();
const input = await screen.findByTestId("repo-dropdown");
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
"kubernetes/kubernetes",
3,
);
const searchedRepo = screen.getByText(MOCK_SEARCH_REPOS[0].full_name);
expect(searchedRepo).toBeInTheDocument();
await userEvent.click(searchedRepo);
expect(mockOnRepoSelection).toHaveBeenCalledWith(MOCK_SEARCH_REPOS[0]);
});
});

View File

@@ -73,7 +73,7 @@ describe("TaskCard", () => {
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({ data: MOCK_RESPOSITORIES, nextPage: null });
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
});
it("should call create conversation with suggest task trigger and selected suggested task", async () => {

View File

@@ -12,23 +12,6 @@ import { GitRepository } from "#/types/git";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { Conversation } from "#/api/open-hands.types";
// Mock hooks
const mockUseUserProviders = vi.fn();
const mockUseUserRepositories = vi.fn();
const mockUseConfig = vi.fn();
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => mockUseUserProviders(),
}));
vi.mock("#/hooks/query/use-user-repositories", () => ({
useUserRepositories: () => mockUseUserRepositories(),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => mockUseConfig(),
}));
describe("MicroagentManagement", () => {
const RouterStub = createRoutesStub([
{
@@ -168,39 +151,10 @@ describe("MicroagentManagement", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
// Setup default hook mocks
mockUseUserProviders.mockReturnValue({
providers: ["github"],
});
mockUseUserRepositories.mockReturnValue({
data: {
pages: [
{
data: mockRepositories,
nextPage: null,
},
],
},
isLoading: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
mockUseConfig.mockReturnValue({
data: {
APP_MODE: "oss",
},
});
// Setup default mock for retrieveUserGitRepositories
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
data: [...mockRepositories],
nextPage: null,
});
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue([
...mockRepositories,
]);
// Setup default mock for getRepositoryMicroagents
vi.spyOn(OpenHands, "getRepositoryMicroagents").mockResolvedValue([
...mockMicroagents,
@@ -226,15 +180,13 @@ describe("MicroagentManagement", () => {
});
it("should display loading state when fetching repositories", async () => {
// Mock loading state
mockUseUserRepositories.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockImplementation(
() => new Promise(() => {}), // Never resolves
);
renderMicroagentManagement();
@@ -244,21 +196,19 @@ describe("MicroagentManagement", () => {
});
it("should handle error when fetching repositories", async () => {
// Mock error state
mockUseUserRepositories.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockRejectedValue(
new Error("Failed to fetch repositories"),
);
renderMicroagentManagement();
// Wait for the error to be handled
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
});
});
@@ -267,7 +217,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Check that tabs are rendered
@@ -285,7 +235,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded and rendered
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Check that repository names are displayed
@@ -300,7 +250,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -337,7 +287,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -362,7 +312,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -387,7 +337,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -410,7 +360,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -449,7 +399,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Check that add microagent buttons are present
@@ -463,7 +413,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -482,7 +432,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -502,28 +452,17 @@ describe("MicroagentManagement", () => {
});
it("should display empty state when no repositories are found", async () => {
// Mock empty repositories
mockUseUserRepositories.mockReturnValue({
data: {
pages: [
{
data: [],
nextPage: null,
},
],
},
isLoading: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue([]);
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
});
// Check that empty state messages are displayed
@@ -540,7 +479,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -581,7 +520,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Check that search input is rendered
@@ -601,7 +540,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Initially only repositories with .openhands should be visible
@@ -632,7 +571,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with uppercase
@@ -655,7 +594,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with partial match
@@ -681,7 +620,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input
@@ -714,7 +653,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with non-existent repository name
@@ -742,7 +681,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with special characters
@@ -763,7 +702,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Filter to show only repo2
@@ -798,7 +737,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with leading/trailing whitespace
@@ -818,7 +757,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
const searchInput = screen.getByRole("textbox", {
@@ -850,7 +789,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -877,7 +816,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -923,7 +862,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -940,7 +879,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -965,7 +904,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1014,7 +953,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1050,7 +989,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1092,7 +1031,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1129,7 +1068,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1173,7 +1112,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1203,7 +1142,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1226,7 +1165,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1253,7 +1192,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1294,7 +1233,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Check that add microagent buttons are present
@@ -1308,7 +1247,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1363,7 +1302,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1387,7 +1326,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1410,7 +1349,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1443,7 +1382,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1470,7 +1409,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1496,7 +1435,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1575,8 +1514,8 @@ describe("MicroagentManagement", () => {
pr_number: null,
};
const renderMicroagentManagementMain = (selectedMicroagentItem: any) =>
renderWithProviders(<MicroagentManagementMain />, {
const renderMicroagentManagementMain = (selectedMicroagentItem: any) => {
return renderWithProviders(<MicroagentManagementMain />, {
preloadedState: {
metrics: {
cost: null,
@@ -1602,6 +1541,7 @@ describe("MicroagentManagement", () => {
},
},
});
};
it("should render MicroagentManagementDefault when no microagent or conversation is selected", async () => {
renderMicroagentManagementMain(null);
@@ -2355,7 +2295,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion to expand it
@@ -2397,7 +2337,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories and expand accordion
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
const repoAccordion = screen.getByTestId("repository-name-tooltip");
@@ -2450,7 +2390,7 @@ describe("MicroagentManagement", () => {
renderMicroagentManagement();
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
const repoAccordion = screen.getByTestId("repository-name-tooltip");

View File

@@ -5,32 +5,16 @@ import { UserActions } from "#/components/features/sidebar/user-actions";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactElement } from "react";
// Create mocks for all the hooks we need
// Create a mock for useIsAuthed that we can control per test
const useIsAuthedMock = vi
.fn()
.mockReturnValue({ data: true, isLoading: false });
const useConfigMock = vi
.fn()
.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
const useUserProvidersMock = vi
.fn()
.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
// Mock the hooks
// Mock the useIsAuthed hook
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => useIsAuthedMock(),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => useConfigMock(),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => useUserProvidersMock(),
}));
describe("UserActions", () => {
const user = userEvent.setup();
const onClickAccountSettingsMock = vi.fn();
@@ -56,10 +40,8 @@ describe("UserActions", () => {
};
beforeEach(() => {
// Reset all mocks to default values before each test
// Reset the mock to default value before each test
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
});
afterEach(() => {
@@ -120,9 +102,6 @@ describe("UserActions", () => {
it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
@@ -152,9 +131,6 @@ describe("UserActions", () => {
it("should NOT be able to access logout when user is not authenticated", async () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
@@ -173,9 +149,6 @@ describe("UserActions", () => {
it("should handle user prop changing from undefined to defined", async () => {
// Start with no authentication
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
const { rerender } = renderWithQueryClient(
<UserActions onLogout={onLogoutMock} />,
@@ -190,9 +163,6 @@ describe("UserActions", () => {
// Set authentication to true for the rerender
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
// Ensure config and providers are set correctly
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
// Add user prop and create a new QueryClient to ensure fresh state
const queryClient = new QueryClient({
@@ -225,11 +195,6 @@ describe("UserActions", () => {
});
it("should handle user prop changing from defined to undefined", async () => {
// Start with authentication and providers
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
const { rerender } = renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
@@ -246,9 +211,6 @@ describe("UserActions", () => {
// Set authentication to false for the rerender
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
// Remove user prop - menu should disappear because user is no longer authenticated
rerender(
@@ -267,11 +229,6 @@ describe("UserActions", () => {
});
it("should work with loading state and user provided", async () => {
// Ensure authentication and providers are set correctly
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}

View File

@@ -3,8 +3,6 @@ import { createRoutesStub } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import i18next from "i18next";
import { I18nextProvider } from "react-i18next";
import GitSettingsScreen from "#/routes/git-settings";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
@@ -48,44 +46,22 @@ const GitSettingsRouterStub = createRoutesStub([
]);
const renderGitSettingsScreen = () => {
// Initialize i18next instance
i18next.init({
lng: "en",
resources: {
en: {
translation: {
GITHUB$TOKEN_HELP_TEXT: "Help text",
GITHUB$TOKEN_LABEL: "GitHub Token",
GITHUB$HOST_LABEL: "GitHub Host",
GITLAB$TOKEN_LABEL: "GitLab Token",
GITLAB$HOST_LABEL: "GitLab Host",
BITBUCKET$TOKEN_LABEL: "Bitbucket Token",
BITBUCKET$HOST_LABEL: "Bitbucket Host",
},
},
},
});
const { rerender, ...rest } = render(
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />,
{
wrapper: ({ children }) => (
<I18nextProvider i18n={i18next}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</I18nextProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
},
);
const rerenderGitSettingsScreen = () =>
rerender(
<I18nextProvider i18n={i18next}>
<QueryClientProvider client={queryClient}>
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />
</QueryClientProvider>
</I18nextProvider>,
<QueryClientProvider client={queryClient}>
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />
</QueryClientProvider>,
);
return {
@@ -375,18 +351,14 @@ describe("Form submission", () => {
let disconnectButton = await screen.findByTestId(
"disconnect-tokens-button",
);
// When tokens are set (github and gitlab are not null), the button should be enabled
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
// Mock settings with no tokens set
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
queryClient.invalidateQueries();
disconnectButton = await screen.findByTestId("disconnect-tokens-button");
// When no tokens are set, the button should be disabled
await waitFor(() => expect(disconnectButton).toBeDisabled());
});

View File

@@ -32,42 +32,6 @@ const RouterStub = createRoutesStub([
},
]);
const selectRepository = async (repoName: string) => {
const repoConnector = screen.getByTestId("repo-connector");
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
const repoInput = within(dropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
await waitFor(() => {
const options = screen.getAllByText(repoName);
// Find the option in the dropdown (it will have role="option")
const dropdownOption = options.find(
(el) => el.getAttribute("role") === "option",
);
expect(dropdownOption).toBeInTheDocument();
});
const options = screen.getAllByText(repoName);
const dropdownOption = options.find(
(el) => el.getAttribute("role") === "option",
);
await userEvent.click(dropdownOption!);
// Wait for the branch to be auto-selected
await waitFor(() => {
expect(screen.getByText("main")).toBeInTheDocument();
});
};
const renderHomeScreen = () =>
render(<RouterStub />, {
wrapper: ({ children }) => (
@@ -129,8 +93,84 @@ describe("HomeScreen", () => {
expect(mainContainer).toHaveClass("flex", "flex-col", "lg:flex-row");
});
// TODO: Fix this test
it.skip("should filter and reset the suggested tasks based on repository selection", async () => {});
it("should filter the suggested tasks based on the selected repository", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
renderHomeScreen();
const taskSuggestions = await screen.findByTestId("task-suggestions");
// Initially, all tasks should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
// Select a repository from the dropdown
const repoConnector = screen.getByTestId("repo-connector");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
await userEvent.click(dropdown);
const repoOption = screen.getAllByText("octocat/hello-world")[1];
await userEvent.click(repoOption);
// After selecting a repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
expect(
within(taskSuggestions).queryByText("octocat/earth"),
).not.toBeInTheDocument();
});
});
it("should reset the filtered tasks when the selected repository is cleared", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
renderHomeScreen();
const taskSuggestions = await screen.findByTestId("task-suggestions");
// Initially, all tasks should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
// Select a repository from the dropdown
const repoConnector = screen.getByTestId("repo-connector");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
await userEvent.click(dropdown);
const repoOption = screen.getAllByText("octocat/hello-world")[1];
await userEvent.click(repoOption);
// After selecting a repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
expect(
within(taskSuggestions).queryByText("octocat/earth"),
).not.toBeInTheDocument();
});
// Clear the selected repository
await userEvent.clear(dropdown);
// All tasks should be visible again
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
});
describe("launch buttons", () => {
const setupLaunchButtons = async () => {
@@ -139,25 +179,19 @@ describe("HomeScreen", () => {
let tasksLaunchButtons =
await screen.findAllByTestId("task-launch-button");
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
]);
// Select a repository from the dropdown to enable the repo launch button
const repoConnector = screen.getByTestId("repo-connector");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
await userEvent.click(dropdown);
const repoOption = screen.getAllByText("octocat/hello-world")[1];
await userEvent.click(repoOption);
// Select a repository to enable the repo launch button
await selectRepository("octocat/hello-world");
// Wait for all buttons to be enabled
await waitFor(() => {
expect(headerLaunchButton).not.toBeDisabled();
expect(repoLaunchButton).not.toBeDisabled();
tasksLaunchButtons.forEach((button) => {
expect(button).not.toBeDisabled();
});
expect(headerLaunchButton).not.toBeDisabled();
expect(repoLaunchButton).not.toBeDisabled();
tasksLaunchButtons.forEach((button) => {
expect(button).not.toBeDisabled();
});
// Get fresh references to the buttons
headerLaunchButton = screen.getByTestId("header-launch-button");
repoLaunchButton = screen.getByTestId("repo-launch-button");
tasksLaunchButtons = await screen.findAllByTestId("task-launch-button");
@@ -174,10 +208,7 @@ describe("HomeScreen", () => {
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
});
it("should disable the other launch buttons when the header launch button is clicked", async () => {

View File

@@ -47,7 +47,7 @@ describe("Content", () => {
const apiKey = screen.getByTestId("llm-api-key-input");
await waitFor(() => {
expect(provider).toHaveValue("OpenHands");
expect(provider).toHaveValue("Anthropic");
expect(model).toHaveValue("claude-sonnet-4-20250514");
expect(apiKey).toHaveValue("");
@@ -135,7 +135,7 @@ describe("Content", () => {
);
const condensor = screen.getByTestId("enable-memory-condenser-switch");
expect(model).toHaveValue("openhands/claude-sonnet-4-20250514");
expect(model).toHaveValue("anthropic/claude-sonnet-4-20250514");
expect(baseUrl).toHaveValue("");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
@@ -537,7 +537,7 @@ describe("Form submission", () => {
// select provider
await userEvent.click(provider);
const providerOption = screen.getByText("OpenHands");
const providerOption = screen.getByText("Anthropic");
await userEvent.click(providerOption);
// select model
@@ -550,7 +550,7 @@ describe("Form submission", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: "openhands/claude-sonnet-4-20250514",
llm_model: "anthropic/claude-sonnet-4-20250514",
llm_base_url: "",
confirmation_mode: false,
}),

View File

@@ -112,21 +112,12 @@ describe("Content", () => {
screen.getByTestId("git-settings-screen");
});
it("should render an empty table when there are no existing secrets", async () => {
it("should render a message if there are no existing secrets", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
renderSecretsSettings();
// Should show the add secret button
await screen.findByTestId("add-secret-button");
// Should show an empty table with headers but no secret items
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
// Should still show the table headers
expect(screen.getByText("SETTINGS$NAME")).toBeInTheDocument();
expect(screen.getByText("SECRETS$DESCRIPTION")).toBeInTheDocument();
expect(screen.getByText("SETTINGS$ACTIONS")).toBeInTheDocument();
await screen.findByTestId("no-secrets-message");
});
it("should render existing secrets", async () => {
@@ -136,6 +127,7 @@ describe("Content", () => {
const secrets = await screen.findAllByTestId("secret-item");
expect(secrets).toHaveLength(2);
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
});
});
@@ -407,22 +399,19 @@ describe("Secret actions", () => {
expect(screen.queryByText("My_Secret_2")).toBeInTheDocument();
});
it("should hide the table and add button when in form view", async () => {
it("should hide the no items message when in form view", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
renderSecretsSettings();
// Initially should show the add button and table
// render form & hide items
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
const button = await screen.findByTestId("add-secret-button");
expect(screen.getByText("SETTINGS$NAME")).toBeInTheDocument(); // table header
await userEvent.click(button);
// When in form view, should hide the add button and table
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument();
expect(screen.queryByText("SETTINGS$NAME")).not.toBeInTheDocument(); // table header should be hidden
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
});
it("should not allow spaces in secret names", async () => {

File diff suppressed because it is too large Load Diff

View File

@@ -8,15 +8,14 @@
},
"dependencies": {
"@heroui/react": "^2.8.2",
"@heroui/use-infinite-scroll": "^2.2.10",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1",
"@react-types/shared": "^3.31.0",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.9.0",
"@stripe/stripe-js": "^7.8.0",
"@stripe/react-stripe-js": "^3.8.1",
"@stripe/stripe-js": "^7.7.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.84.1",
@@ -44,7 +43,6 @@
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.7.1",
"react-select": "^5.10.2",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.9",
"remark-breaks": "^4.0.0",
@@ -94,7 +92,7 @@
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.2.0",
"@types/node": "^24.1.0",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@types/react-highlight": "^0.12.8",
@@ -112,13 +110,13 @@
"eslint-plugin-i18next": "^6.1.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.1.4",
"husky": "^9.1.7",
"jsdom": "^26.1.0",
"lint-staged": "^16.1.4",
"lint-staged": "^16.1.2",
"msw": "^2.6.6",
"prettier": "^3.6.2",
"stripe": "^18.4.0",

View File

@@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.10.4'
const PACKAGE_VERSION = '2.10.3'
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

View File

@@ -20,7 +20,6 @@ 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 { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
@@ -282,7 +281,7 @@ class OpenHands {
static async getUserConversations(): Promise<Conversation[]> {
const { data } = await openHands.get<ResultSet<Conversation>>(
"/api/conversations?limit=100",
"/api/conversations?limit=20",
);
return data.results;
}
@@ -435,7 +434,6 @@ class OpenHands {
static async searchGitRepositories(
query: string,
per_page = 5,
selected_provider?: Provider,
): Promise<GitRepository[]> {
const response = await openHands.get<GitRepository[]>(
"/api/user/search/repositories",
@@ -443,7 +441,6 @@ class OpenHands {
params: {
query,
per_page,
selected_provider,
},
},
);
@@ -488,70 +485,20 @@ class OpenHands {
}
/**
* Given a PAT, retrieves the repositories of the user
* @returns A list of repositories
*/
static async retrieveUserGitRepositories(
selected_provider: Provider,
page = 1,
per_page = 30,
) {
static async retrieveUserGitRepositories() {
const { data } = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
selected_provider,
sort: "pushed",
page,
per_page,
},
},
);
const link =
data.length > 0 && data[0].link_header ? data[0].link_header : "";
const nextPage = extractNextPageFromLink(link);
return { data, nextPage };
}
static async retrieveInstallationRepositories(
selected_provider: Provider,
installationIndex: number,
installations: string[],
page = 1,
per_page = 30,
) {
const installationId = installations[installationIndex];
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
selected_provider,
sort: "pushed",
page,
per_page,
installation_id: installationId,
},
},
);
const link =
response.data.length > 0 && response.data[0].link_header
? response.data[0].link_header
: "";
const nextPage = extractNextPageFromLink(link);
let nextInstallation: number | null;
if (nextPage) {
nextInstallation = installationIndex;
} else if (installationIndex + 1 < installations.length) {
nextInstallation = installationIndex + 1;
} else {
nextInstallation = null;
}
return {
data: response.data,
nextPage,
installationIndex: nextInstallation,
};
return data;
}
static async getRepositoryBranches(repository: string): Promise<Branch[]> {
@@ -639,18 +586,6 @@ class OpenHands {
return data;
}
/**
* Get the user installation IDs
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
* @returns List of installation IDs
*/
static async getUserInstallationIds(provider: Provider): Promise<string[]> {
const { data } = await openHands.get<string[]>(
`/api/user/installations?provider=${provider}`,
);
return data;
}
}
export default OpenHands;

View File

@@ -51,7 +51,6 @@ export interface GetConfigResponse {
POSTHOG_CLIENT_KEY: string;
STRIPE_PUBLISHABLE_KEY?: string;
PROVIDERS_CONFIGURED?: Provider[];
AUTH_URL?: string;
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;

View File

@@ -1,69 +0,0 @@
import { useMemo } from "react";
import { useRepositoryBranches } from "../../hooks/query/use-repository-branches";
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
export interface GitBranchDropdownProps {
repositoryName?: string | null;
value?: string | null;
placeholder?: string;
className?: string;
errorMessage?: string;
disabled?: boolean;
onChange?: (branchName: string | null) => void;
}
export function GitBranchDropdown({
repositoryName,
value,
placeholder = "Select branch...",
className,
errorMessage,
disabled = false,
onChange,
}: GitBranchDropdownProps) {
const { data: branches, isLoading } = useRepositoryBranches(
repositoryName || null,
);
const options: SelectOption[] = useMemo(
() =>
branches?.map((branch) => ({
value: branch.name,
label: branch.name,
})) || [],
[branches],
);
const hasNoBranches = !isLoading && branches && branches.length === 0;
const selectedOption = useMemo(
() => options.find((option) => option.value === value) || null,
[options, value],
);
const handleChange = (option: SelectOption | null) => {
onChange?.(option?.value || null);
};
const isDisabled = disabled || !repositoryName || isLoading || hasNoBranches;
const displayPlaceholder = hasNoBranches ? "No branches found" : placeholder;
const displayErrorMessage = hasNoBranches
? "This repository has no branches"
: errorMessage;
return (
<ReactSelectDropdown
options={options}
value={selectedOption}
placeholder={displayPlaceholder}
className={className}
errorMessage={displayErrorMessage}
disabled={isDisabled}
isClearable={false}
isSearchable
isLoading={isLoading}
onChange={handleChange}
/>
);
}

View File

@@ -1,58 +0,0 @@
import { useMemo } from "react";
import { Provider } from "../../types/settings";
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
export interface GitProviderDropdownProps {
providers: Provider[];
value?: Provider | null;
placeholder?: string;
className?: string;
errorMessage?: string;
disabled?: boolean;
isLoading?: boolean;
onChange?: (provider: Provider | null) => void;
}
export function GitProviderDropdown({
providers,
value,
placeholder = "Select Provider",
className,
errorMessage,
disabled = false,
isLoading = false,
onChange,
}: GitProviderDropdownProps) {
const options: SelectOption[] = useMemo(
() =>
providers.map((provider) => ({
value: provider,
label: provider.charAt(0).toUpperCase() + provider.slice(1),
})),
[providers],
);
const selectedOption = useMemo(
() => options.find((option) => option.value === value) || null,
[options, value],
);
const handleChange = (option: SelectOption | null) => {
onChange?.(option?.value as Provider | null);
};
return (
<ReactSelectDropdown
options={options}
value={selectedOption}
placeholder={placeholder}
className={className}
errorMessage={errorMessage}
disabled={disabled}
isClearable={false}
isSearchable={false}
isLoading={isLoading}
onChange={handleChange}
/>
);
}

View File

@@ -1,186 +0,0 @@
import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../../types/settings";
import { useGitRepositories } from "../../hooks/query/use-git-repositories";
import OpenHands from "../../api/open-hands";
import { GitRepository } from "../../types/git";
import {
ReactSelectAsyncDropdown,
AsyncSelectOption,
} from "./react-select-async-dropdown";
export interface GitRepositoryDropdownProps {
provider: Provider;
value?: string | null;
placeholder?: string;
className?: string;
errorMessage?: string;
disabled?: boolean;
onChange?: (repository?: GitRepository) => void;
}
interface SearchCache {
[key: string]: GitRepository[];
}
export function GitRepositoryDropdown({
provider,
value,
placeholder = "Search repositories...",
className,
errorMessage,
disabled = false,
onChange,
}: GitRepositoryDropdownProps) {
const { t } = useTranslation();
const {
data,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
isError,
} = useGitRepositories({
provider,
enabled: !disabled,
});
const allOptions: AsyncSelectOption[] = useMemo(
() =>
data?.pages
? data.pages.flatMap((page) =>
page.data.map((repo) => ({
value: repo.id,
label: repo.full_name,
})),
)
: [],
[data],
);
// Keep track of search results
const searchCache = useRef<SearchCache>({});
const selectedOption = useMemo(() => {
// First check in loaded pages
const option = allOptions.find((opt) => opt.value === value);
if (option) return option;
// If not found, check in search cache
const repo = Object.values(searchCache.current)
.flat()
.find((r) => r.id === value);
if (repo) {
return {
value: repo.id,
label: repo.full_name,
};
}
return null;
}, [allOptions, value]);
const loadOptions = useCallback(
async (inputValue: string): Promise<AsyncSelectOption[]> => {
// If empty input, show all loaded options
if (!inputValue.trim()) {
return allOptions;
}
// If it looks like a URL, extract the repo name and search
if (inputValue.startsWith("https://")) {
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
if (match) {
const repoName = match[1];
const searchResults = await OpenHands.searchGitRepositories(
repoName,
3,
);
// Cache the search results
searchCache.current[repoName] = searchResults;
return searchResults.map((repo) => ({
value: repo.id,
label: repo.full_name,
}));
}
}
// For any other input, search via API
if (inputValue.length >= 2) {
// Only search if at least 2 characters
const searchResults = await OpenHands.searchGitRepositories(
inputValue,
10,
);
// Cache the search results
searchCache.current[inputValue] = searchResults;
return searchResults.map((repo) => ({
value: repo.id,
label: repo.full_name,
}));
}
// For very short inputs, do local filtering
return allOptions.filter((option) =>
option.label.toLowerCase().includes(inputValue.toLowerCase()),
);
},
[allOptions],
);
const handleChange = (option: AsyncSelectOption | null) => {
if (!option) {
onChange?.(undefined);
return;
}
// First check in loaded pages
let repo = data?.pages
?.flatMap((p) => p.data)
.find((r) => r.id === option.value);
// If not found, check in search results
if (!repo) {
repo = Object.values(searchCache.current)
.flat()
.find((r) => r.id === option.value);
}
onChange?.(repo);
};
const handleMenuScrollToBottom = useCallback(() => {
if (hasNextPage && !isFetchingNextPage && !isLoading) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, isLoading, fetchNextPage]);
return (
<>
<ReactSelectAsyncDropdown
testId="repo-dropdown"
loadOptions={loadOptions}
value={selectedOption}
placeholder={placeholder}
className={className}
errorMessage={errorMessage}
disabled={disabled}
isClearable={false}
isLoading={isLoading || isLoading || isFetchingNextPage}
cacheOptions
defaultOptions={allOptions}
onChange={handleChange}
onMenuScrollToBottom={handleMenuScrollToBottom}
/>
{isError && (
<div
data-testid="repo-dropdown-error"
className="text-red-500 text-sm mt-1"
>
{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}
</div>
)}
</>
);
}

View File

@@ -1,79 +0,0 @@
import { useCallback, useMemo } from "react";
import AsyncSelect from "react-select/async";
import { cn } from "#/utils/utils";
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
export type AsyncSelectOption = SelectOptionBase;
export interface ReactSelectAsyncDropdownProps {
loadOptions: (inputValue: string) => Promise<AsyncSelectOption[]>;
testId?: string;
placeholder?: string;
value?: AsyncSelectOption | null;
defaultValue?: AsyncSelectOption | null;
className?: string;
errorMessage?: string;
disabled?: boolean;
isClearable?: boolean;
isLoading?: boolean;
cacheOptions?: boolean;
defaultOptions?: boolean | AsyncSelectOption[];
onChange?: (option: AsyncSelectOption | null) => void;
onMenuScrollToBottom?: () => void;
}
export function ReactSelectAsyncDropdown({
loadOptions,
testId,
placeholder = "Search...",
value,
defaultValue,
className,
errorMessage,
disabled = false,
isClearable = false,
isLoading = false,
cacheOptions = true,
defaultOptions = true,
onChange,
onMenuScrollToBottom,
}: ReactSelectAsyncDropdownProps) {
const customStyles = useMemo(() => getCustomStyles<AsyncSelectOption>(), []);
const handleLoadOptions = useCallback(
(inputValue: string, callback: (options: AsyncSelectOption[]) => void) => {
loadOptions(inputValue)
.then((options) => callback(options))
.catch(() => callback([]));
},
[loadOptions],
);
return (
<div data-testid={testId} className={cn("w-full", className)}>
<AsyncSelect
loadOptions={handleLoadOptions}
value={value}
defaultValue={defaultValue}
placeholder={placeholder}
isDisabled={disabled}
isClearable={isClearable}
isLoading={isLoading}
cacheOptions={cacheOptions}
defaultOptions={defaultOptions}
onChange={onChange}
onMenuScrollToBottom={onMenuScrollToBottom}
styles={customStyles}
className="w-full"
/>
{errorMessage && (
<p
data-testid="repo-dropdown-error"
className="text-red-500 text-sm mt-1"
>
{errorMessage}
</p>
)}
</div>
);
}

View File

@@ -1,57 +0,0 @@
import { useMemo } from "react";
import Select from "react-select";
import { cn } from "#/utils/utils";
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
export type SelectOption = SelectOptionBase;
export interface ReactSelectDropdownProps {
options: SelectOption[];
placeholder?: string;
value?: SelectOption | null;
defaultValue?: SelectOption | null;
className?: string;
errorMessage?: string;
disabled?: boolean;
isClearable?: boolean;
isSearchable?: boolean;
isLoading?: boolean;
onChange?: (option: SelectOption | null) => void;
}
export function ReactSelectDropdown({
options,
placeholder = "Select option...",
value,
defaultValue,
className,
errorMessage,
disabled = false,
isClearable = false,
isSearchable = true,
isLoading = false,
onChange,
}: ReactSelectDropdownProps) {
const customStyles = useMemo(() => getCustomStyles<SelectOption>(), []);
return (
<div className={cn("w-full", className)}>
<Select
options={options}
value={value}
defaultValue={defaultValue}
placeholder={placeholder}
isDisabled={disabled}
isClearable={isClearable}
isSearchable={isSearchable}
isLoading={isLoading}
onChange={onChange}
styles={customStyles}
className="w-full"
/>
{errorMessage && (
<p className="text-red-500 text-sm mt-1">{errorMessage}</p>
)}
</div>
);
}

View File

@@ -1,92 +0,0 @@
import { StylesConfig } from "react-select";
export interface SelectOptionBase {
value: string;
label: string;
}
export const getCustomStyles = <T extends SelectOptionBase>(): StylesConfig<
T,
false
> => ({
control: (provided, state) => ({
...provided,
backgroundColor: state.isDisabled ? "#363636" : "#454545", // darker tertiary when disabled
border: "1px solid #717888",
borderRadius: "0.125rem",
minHeight: "2.5rem",
padding: "0 0.5rem",
boxShadow: state.isFocused ? "0 0 0 1px #717888" : "none",
opacity: state.isDisabled ? 0.6 : 1,
cursor: state.isDisabled ? "not-allowed" : "pointer",
"&:hover": {
borderColor: "#717888",
},
}),
input: (provided) => ({
...provided,
color: "#ECEDEE", // content
}),
placeholder: (provided) => ({
...provided,
fontStyle: "italic",
color: "#B7BDC2", // tertiary-light
}),
singleValue: (provided, state) => ({
...provided,
color: state.isDisabled ? "#B7BDC2" : "#ECEDEE", // tertiary-light when disabled, content otherwise
}),
menu: (provided) => ({
...provided,
backgroundColor: "#454545", // tertiary
border: "1px solid #717888",
borderRadius: "0.75rem",
overflow: "hidden", // ensure menu items don't overflow rounded corners
}),
menuList: (provided) => ({
...provided,
padding: "0.25rem", // add some padding around menu items
}),
option: (provided, state) => {
let backgroundColor = "transparent";
if (state.isSelected) {
backgroundColor = "#C9B974"; // primary for selected
} else if (state.isFocused) {
backgroundColor = "#24272E"; // base-secondary for hover/focus
}
return {
...provided,
backgroundColor,
color: state.isSelected ? "#000000" : "#ECEDEE", // black text on yellow, white on gray
borderRadius: "0.5rem", // rounded menu items
margin: "0.125rem 0", // small gap between items
"&:hover": {
backgroundColor: state.isSelected ? "#C9B974" : "#24272E", // keep yellow if selected, else gray
color: state.isSelected ? "#000000" : "#ECEDEE", // maintain text color on hover
},
"&:active": {
backgroundColor: state.isSelected ? "#C9B974" : "#24272E",
color: state.isSelected ? "#000000" : "#ECEDEE",
},
};
},
clearIndicator: (provided) => ({
...provided,
color: "#B7BDC2", // tertiary-light
"&:hover": {
color: "#ECEDEE", // content
},
}),
dropdownIndicator: (provided) => ({
...provided,
color: "#B7BDC2", // tertiary-light
"&:hover": {
color: "#ECEDEE", // content
},
}),
loadingIndicator: (provided) => ({
...provided,
color: "#B7BDC2", // tertiary-light
}),
});

View File

@@ -55,7 +55,7 @@ export function ChatMessage({
className={cn(
"rounded-xl relative w-fit",
"flex flex-col gap-2",
type === "user" && " p-4 bg-tertiary self-end",
type === "user" && " max-w-[305px] p-4 bg-tertiary self-end",
type === "agent" && "mt-6 max-w-full bg-transparent",
)}
>
@@ -86,13 +86,7 @@ export function ChatMessage({
/>
</div>
<div
className="text-sm"
style={{
whiteSpace: "normal",
wordBreak: "break-word",
}}
>
<div className="text-sm break-words">
<Markdown
components={{
code,

View File

@@ -1,12 +1,10 @@
import { useTranslation } from "react-i18next";
import { FaInfoCircle } from "react-icons/fa";
import { ConnectToProviderMessage } from "./connect-to-provider-message";
import { RepositorySelectionForm } from "./repo-selection-form";
import { useConfig } from "#/hooks/query/use-config";
import { RepoProviderLinks } from "./repo-provider-links";
import { useUserProviders } from "#/hooks/use-user-providers";
import { GitRepository } from "#/types/git";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
interface RepoConnectorProps {
onRepoSelection: (repo: GitRepository | null) => void;
@@ -25,19 +23,7 @@ export function RepoConnector({ onRepoSelection }: RepoConnectorProps) {
data-testid="repo-connector"
className="w-full flex flex-col gap-6"
>
<div className="flex items-center gap-2">
<h2 className="heading">{t("HOME$CONNECT_TO_REPOSITORY")}</h2>
<TooltipButton
testId="repo-connector-info"
tooltip={t("HOME$CONNECT_TO_REPOSITORY_TOOLTIP")}
ariaLabel={t("HOME$CONNECT_TO_REPOSITORY_TOOLTIP")}
className="text-[#9099AC] hover:text-white"
placement="bottom"
tooltipClassName="max-w-[348px]"
>
<FaInfoCircle size={16} />
</TooltipButton>
</div>
<h2 className="heading">{t("HOME$CONNECT_TO_REPOSITORY")}</h2>
{!providersAreSet && <ConnectToProviderMessage />}
{providersAreSet && (

View File

@@ -2,15 +2,22 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { Branch, GitRepository } from "#/types/git";
import { BrandButton } from "../settings/brand-button";
import { useUserProviders } from "#/hooks/use-user-providers";
import { Provider } from "#/types/settings";
import { GitProviderDropdown } from "../../common/git-provider-dropdown";
import { GitRepositoryDropdown } from "../../common/git-repository-dropdown";
import { GitBranchDropdown } from "../../common/git-branch-dropdown";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useDebounce } from "#/hooks/use-debounce";
import { sanitizeQuery } from "#/utils/sanitize-query";
import {
RepositoryDropdown,
RepositoryLoadingState,
RepositoryErrorState,
BranchDropdown,
BranchLoadingState,
BranchErrorState,
} from "./repository-selection";
interface RepositorySelectionFormProps {
onRepoSelection: (repo: GitRepository | null) => void;
@@ -25,11 +32,18 @@ export function RepositorySelectionForm({
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
null,
);
const [selectedProvider, setSelectedProvider] =
React.useState<Provider | null>(null);
const { providers } = useUserProviders();
const { data: branches, isLoading: isLoadingBranches } =
useRepositoryBranches(selectedRepository?.full_name || null);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = React.useRef<boolean>(false);
const {
data: repositories,
isLoading: isLoadingRepositories,
isError: isRepositoriesError,
} = useUserRepositories();
const {
data: branches,
isLoading: isLoadingBranches,
isError: isBranchesError,
} = useRepositoryBranches(selectedRepository?.full_name || null);
const {
mutate: createConversation,
isPending,
@@ -38,108 +52,151 @@ export function RepositorySelectionForm({
const isCreatingConversationElsewhere = useIsCreatingConversation();
const { t } = useTranslation();
// Auto-select provider if there's only one
const [searchQuery, setSearchQuery] = React.useState("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery);
// Auto-select main or master branch if it exists, but only if the branch wasn't manually cleared
React.useEffect(() => {
if (providers.length === 1 && !selectedProvider) {
setSelectedProvider(providers[0]);
if (
branches &&
branches.length > 0 &&
!selectedBranch &&
!isLoadingBranches &&
!branchManuallyClearedRef.current // Only auto-select if not manually cleared
) {
// 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);
}
}
}, [providers, selectedProvider]);
}, [branches, isLoadingBranches, selectedBranch]);
// We check for isSuccess because the app might require time to render
// into the new conversation screen after the conversation is created.
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
// Check if repository has no branches (empty array after loading completes)
const hasNoBranches = !isLoadingBranches && branches && branches.length === 0;
const allRepositories = repositories?.concat(searchedRepos || []);
const repositoriesItems = allRepositories?.map((repo) => ({
key: repo.id,
label: decodeURIComponent(repo.full_name),
}));
const handleProviderSelection = (provider: 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 branchesItems = branches?.map((branch) => ({
key: branch.name,
label: branch.name,
}));
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
if (selectedRepo) onRepoSelection(selectedRepo);
setSelectedRepository(selectedRepo || null);
setSelectedBranch(null); // Reset branch selection when repo changes
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
};
const handleBranchSelection = (branchName: string | null) => {
const selectedBranchObj = branches?.find(
(branch) => branch.name === branchName,
);
if (selectedBranchObj) {
setSelectedBranch(selectedBranchObj);
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 handleRepoInputChange = (value: string) => {
if (value === "") {
setSelectedRepository(null);
setSelectedBranch(null);
onRepoSelection(null);
} else if (value.startsWith("https://")) {
const repoName = sanitizeQuery(value);
setSearchQuery(repoName);
}
};
// Render the provider dropdown
const renderProviderSelector = () => {
// Only render if there are multiple providers
if (providers.length <= 1) {
return null;
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;
}
return (
<GitProviderDropdown
providers={providers}
value={selectedProvider}
placeholder="Select Provider"
className="max-w-[500px]"
onChange={handleProviderSelection}
/>
);
};
// Effect to auto-select main/master branch when branches are loaded
React.useEffect(() => {
if (branches?.length) {
// Look for main or master branch
const defaultBranch = branches.find(
(branch) => branch.name === "main" || branch.name === "master",
);
// If found, select it, otherwise select the first branch
setSelectedBranch(defaultBranch || branches[0]);
}
}, [branches]);
// Render the repository selector using our new component
// Render the appropriate UI based on the loading/error state
const renderRepositorySelector = () => {
const handleRepoSelection = (repository?: GitRepository) => {
if (repository) {
onRepoSelection(repository);
setSelectedRepository(repository);
} else {
setSelectedRepository(null);
setSelectedBranch(null);
}
};
if (isLoadingRepositories) {
return <RepositoryLoadingState />;
}
if (isRepositoriesError) {
return <RepositoryErrorState />;
}
return (
<GitRepositoryDropdown
provider={selectedProvider || providers[0]}
value={selectedRepository?.id || null}
placeholder="Search repositories..."
disabled={!selectedProvider}
onChange={handleRepoSelection}
className="max-w-[500px]"
<RepositoryDropdown
items={repositoriesItems || []}
onSelectionChange={handleRepoSelection}
onInputChange={handleRepoInputChange}
defaultFilter={(textValue, inputValue) => {
if (!inputValue) return true;
const repo = allRepositories?.find((r) => r.full_name === textValue);
if (!repo) return false;
const sanitizedInput = sanitizeQuery(inputValue);
return sanitizeQuery(textValue).includes(sanitizedInput);
}}
/>
);
};
// Render the branch selector
const renderBranchSelector = () => (
<GitBranchDropdown
repositoryName={selectedRepository?.full_name}
value={selectedBranch?.name || null}
placeholder="Select branch..."
className="max-w-[500px]"
disabled={!selectedRepository}
onChange={handleBranchSelection}
/>
);
// Render the appropriate UI for branch selector based on the loading/error state
const renderBranchSelector = () => {
if (!selectedRepository) {
return (
<BranchDropdown
items={[]}
onSelectionChange={() => {}}
onInputChange={() => {}}
isDisabled
/>
);
}
if (isLoadingBranches) {
return <BranchLoadingState />;
}
if (isBranchesError) {
return <BranchErrorState />;
}
return (
<BranchDropdown
items={branchesItems || []}
onSelectionChange={handleBranchSelection}
onInputChange={handleBranchInputChange}
isDisabled={false}
selectedKey={selectedBranch?.name}
/>
);
};
return (
<div className="flex flex-col gap-4">
{renderProviderSelector()}
{renderRepositorySelector()}
{renderBranchSelector()}
<BrandButton
@@ -148,10 +205,9 @@ export function RepositorySelectionForm({
type="button"
isDisabled={
!selectedRepository ||
(!selectedBranch && !hasNoBranches) ||
isLoadingBranches ||
isCreatingConversation ||
(providers.length > 1 && !selectedProvider)
isLoadingRepositories ||
isRepositoriesError
}
onClick={() =>
createConversation(
@@ -159,7 +215,7 @@ export function RepositorySelectionForm({
repository: {
name: selectedRepository?.full_name || "",
gitProvider: selectedRepository?.git_provider || "github",
branch: selectedBranch?.name || (hasNoBranches ? "" : "main"),
branch: selectedBranch?.name || "main",
},
},
{

View File

@@ -1,3 +1,6 @@
export { RepositoryDropdown } from "#/components/features/home/repository-selection/repository-dropdown";
export { RepositoryLoadingState } from "#/components/features/home/repository-selection/repository-loading-state";
export { RepositoryErrorState } from "#/components/features/home/repository-selection/repository-error-state";
export { BranchDropdown } from "#/components/features/home/repository-selection/branch-dropdown";
export { BranchLoadingState } from "#/components/features/home/repository-selection/branch-loading-state";
export { BranchErrorState } from "#/components/features/home/repository-selection/branch-error-state";

View File

@@ -0,0 +1,33 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
import { I18nKey } from "#/i18n/declaration";
export interface RepositoryDropdownProps {
items: { key: React.Key; label: string }[];
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
defaultFilter?: (textValue: string, inputValue: string) => boolean;
}
export function RepositoryDropdown({
items,
onSelectionChange,
onInputChange,
defaultFilter,
}: RepositoryDropdownProps) {
const { t } = useTranslation();
return (
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder={t(I18nKey.REPOSITORY$SELECT_REPO)}
items={items}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
defaultFilter={defaultFilter}
/>
);
}

View File

@@ -0,0 +1,14 @@
import React from "react";
import { useTranslation } from "react-i18next";
export function RepositoryErrorState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-error"
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_REPOSITORIES")}</span>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
export function RepositoryLoadingState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-loading"
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_REPOSITORIES")}</span>
</div>
);
}

View File

@@ -5,7 +5,6 @@ import { Spinner } from "@heroui/react";
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useUserProviders } from "#/hooks/use-user-providers";
import {
setPersonalRepositories,
setOrganizationRepositories,
@@ -23,21 +22,15 @@ export function MicroagentManagementSidebar({
}: MicroagentManagementSidebarProps) {
const dispatch = useDispatch();
const { t } = useTranslation();
const { providers } = useUserProviders();
const selectedProvider = providers.length > 0 ? providers[0] : null;
const { data: repositories, isLoading } =
useUserRepositories(selectedProvider);
const { data: repositories, isLoading } = useUserRepositories();
useEffect(() => {
if (repositories?.pages) {
if (repositories) {
const personalRepos: GitRepository[] = [];
const organizationRepos: GitRepository[] = [];
const otherRepos: GitRepository[] = [];
// Flatten all pages to get all repositories
const allRepositories = repositories.pages.flatMap((page) => page.data);
allRepositories.forEach((repo: GitRepository) => {
repositories.forEach((repo: GitRepository) => {
const hasOpenHandsSuffix = repo.full_name.endsWith("/.openhands");
if (repo.owner_type === "user" && hasOpenHandsSuffix) {

View File

@@ -1,5 +1,5 @@
import { Autocomplete, AutocompleteItem } from "@heroui/react";
import React, { ReactNode } from "react";
import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { OptionalTag } from "./optional-tag";
import { cn } from "#/utils/utils";
@@ -44,7 +44,6 @@ export function SettingsDropdownInput({
defaultFilter,
}: SettingsDropdownInputProps) {
const { t } = useTranslation();
return (
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
{label && (

View File

@@ -1,7 +1,7 @@
import React from "react";
import { UserAvatar } from "./user-avatar";
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
interface UserActionsProps {
onLogout: () => void;
@@ -10,12 +10,10 @@ interface UserActionsProps {
}
export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
const { data: isAuthed } = useIsAuthed();
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
React.useState(false);
// Use the shared hook to determine if user actions should be shown
const shouldShowUserActions = useShouldShowUserFeatures();
const toggleAccountMenu = () => {
// Always toggle the menu, even if user is undefined
setAccountContextMenuIsVisible((prev) => !prev);
@@ -30,8 +28,8 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
closeAccountMenu();
};
// Show the menu based on the new logic
const showMenu = accountContextMenuIsVisible && shouldShowUserActions;
// Always show the menu for authenticated users, even without user data
const showMenu = accountContextMenuIsVisible && isAuthed;
return (
<div data-testid="user-actions" className="w-8 h-8 relative cursor-pointer">

View File

@@ -15,14 +15,12 @@ import { Provider } from "#/types/settings";
interface AuthModalProps {
githubAuthUrl: string | null;
appMode?: GetConfigResponse["APP_MODE"] | null;
authUrl?: GetConfigResponse["AUTH_URL"];
providersConfigured?: Provider[];
}
export function AuthModal({
githubAuthUrl,
appMode,
authUrl,
providersConfigured,
}: AuthModalProps) {
const { t } = useTranslation();
@@ -30,19 +28,16 @@ export function AuthModal({
const gitlabAuthUrl = useAuthUrl({
appMode: appMode || null,
identityProvider: "gitlab",
authUrl,
});
const bitbucketAuthUrl = useAuthUrl({
appMode: appMode || null,
identityProvider: "bitbucket",
authUrl,
});
const enterpriseSsoUrl = useAuthUrl({
appMode: appMode || null,
identityProvider: "enterprise_sso",
authUrl,
});
const handleGitHubAuth = () => {

View File

@@ -67,9 +67,9 @@ prepareApp().then(() =>
<QueryClientProvider client={queryClient}>
<HydratedRouter />
<PosthogInit />
<div id="modal-portal-exit" />
</QueryClientProvider>
</Provider>
<div id="modal-portal-exit" />
</StrictMode>,
);
}),

View File

@@ -27,10 +27,6 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
search_api_key: settings.SEARCH_API_KEY?.trim() || "",
max_budget_per_task: settings.MAX_BUDGET_PER_TASK,
git_user_name:
settings.GIT_USER_NAME?.trim() || DEFAULT_SETTINGS.GIT_USER_NAME,
git_user_email:
settings.GIT_USER_EMAIL?.trim() || DEFAULT_SETTINGS.GIT_USER_EMAIL,
};
await OpenHands.saveSettings(apiSettings);

View File

@@ -1,24 +0,0 @@
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";
import { Provider } from "#/types/settings";
import { shouldUseInstallationRepos } from "#/utils/utils";
export const useAppInstallations = (selectedProvider: Provider | null) => {
const { data: config } = useConfig();
const { data: userIsAuthenticated } = useIsAuthed();
const { providers } = useUserProviders();
return useQuery({
queryKey: ["installations", providers || [], selectedProvider],
queryFn: () => OpenHands.getUserInstallationIds(selectedProvider!),
enabled:
userIsAuthenticated &&
!!selectedProvider &&
shouldUseInstallationRepos(selectedProvider, config?.APP_MODE),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};

View File

@@ -1,130 +0,0 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useUserProviders } from "../use-user-providers";
import { useAppInstallations } from "./use-app-installations";
import { GitRepository } from "../../types/git";
import { Provider } from "../../types/settings";
import OpenHands from "#/api/open-hands";
import { shouldUseInstallationRepos } from "#/utils/utils";
interface UseGitRepositoriesOptions {
provider: Provider | null;
pageSize?: number;
enabled?: boolean;
}
interface UserRepositoriesResponse {
data: GitRepository[];
nextPage: number | null;
}
interface InstallationRepositoriesResponse {
data: GitRepository[];
nextPage: number | null;
installationIndex: number | null;
}
export function useGitRepositories(options: UseGitRepositoriesOptions) {
const { provider, pageSize = 30, enabled = true } = options;
const { providers } = useUserProviders();
const { data: config } = useConfig();
const { data: installations } = useAppInstallations(provider);
const useInstallationRepos = provider
? shouldUseInstallationRepos(provider, config?.APP_MODE)
: false;
const repos = useInfiniteQuery<
UserRepositoriesResponse | InstallationRepositoriesResponse
>({
queryKey: [
"repositories",
providers || [],
provider,
useInstallationRepos,
pageSize,
...(useInstallationRepos ? [installations || []] : []),
],
queryFn: async ({ pageParam }) => {
if (!provider) {
throw new Error("Provider is required");
}
if (useInstallationRepos) {
const { repoPage, installationIndex } = pageParam as {
installationIndex: number | null;
repoPage: number | null;
};
if (!installations) {
throw new Error("Missing installation list");
}
return OpenHands.retrieveInstallationRepositories(
provider,
installationIndex || 0,
installations,
repoPage || 1,
pageSize,
);
}
return OpenHands.retrieveUserGitRepositories(
provider,
pageParam as number,
pageSize,
);
},
getNextPageParam: (lastPage) => {
if (useInstallationRepos) {
const installationPage = lastPage as InstallationRepositoriesResponse;
if (installationPage.nextPage) {
return {
installationIndex: installationPage.installationIndex,
repoPage: installationPage.nextPage,
};
}
if (installationPage.installationIndex !== null) {
return {
installationIndex: installationPage.installationIndex,
repoPage: 1,
};
}
return null;
}
const userPage = lastPage as UserRepositoriesResponse;
return userPage.nextPage;
},
initialPageParam: useInstallationRepos
? { installationIndex: 0, repoPage: 1 }
: 1,
enabled:
enabled &&
(providers || []).length > 0 &&
!!provider &&
(!useInstallationRepos ||
(Array.isArray(installations) && installations.length > 0)),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
refetchOnWindowFocus: false,
});
const onLoadMore = () => {
if (repos.hasNextPage && !repos.isFetchingNextPage) {
repos.fetchNextPage();
}
};
return {
data: repos.data,
isLoading: repos.isLoading,
isError: repos.isError,
hasNextPage: repos.hasNextPage,
isFetchingNextPage: repos.isFetchingNextPage,
fetchNextPage: repos.fetchNextPage,
onLoadMore,
};
}

View File

@@ -3,18 +3,16 @@ import React from "react";
import posthog from "posthog-js";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
export const useGitUser = () => {
const { data: config } = useConfig();
// Use the shared hook to determine if we should fetch user data
const shouldFetchUser = useShouldShowUserFeatures();
const { data: isAuthed } = useIsAuthed();
const user = useQuery({
queryKey: ["user"],
queryFn: OpenHands.getGitUser,
enabled: shouldFetchUser,
enabled: !!config?.APP_MODE && isAuthed, // Enable regardless of providers
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes

View File

@@ -1,82 +0,0 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useAppInstallations } from "./use-app-installations";
import { useConfig } from "./use-config";
import { useUserProviders } from "../use-user-providers";
import { Provider } from "#/types/settings";
import OpenHands from "#/api/open-hands";
import { shouldUseInstallationRepos } from "#/utils/utils";
export const useInstallationRepositories = (
selectedProvider: Provider | null,
) => {
const { providers } = useUserProviders();
const { data: config } = useConfig();
const { data: installations } = useAppInstallations(selectedProvider);
const repos = useInfiniteQuery({
queryKey: [
"repositories",
providers || [],
selectedProvider,
installations || [],
],
queryFn: async ({
pageParam,
}: {
pageParam: { installationIndex: number | null; repoPage: number | null };
}) => {
const { repoPage, installationIndex } = pageParam;
if (!installations) {
throw new Error("Missing installation list");
}
return OpenHands.retrieveInstallationRepositories(
selectedProvider!,
installationIndex || 0,
installations,
repoPage || 1,
30,
);
},
initialPageParam: { installationIndex: 0, repoPage: 1 },
getNextPageParam: (lastPage) => {
if (lastPage.nextPage) {
return {
installationIndex: lastPage.installationIndex,
repoPage: lastPage.nextPage,
};
}
if (lastPage.installationIndex !== null) {
return { installationIndex: lastPage.installationIndex, repoPage: 1 };
}
return null;
},
enabled:
(providers || []).length > 0 &&
!!selectedProvider &&
shouldUseInstallationRepos(selectedProvider, config?.APP_MODE) &&
Array.isArray(installations) &&
installations.length > 0,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
const onLoadMore = () => {
if (repos.hasNextPage && !repos.isFetchingNextPage) {
repos.fetchNextPage();
}
};
// Return the query result with the scroll ref
return {
data: repos.data,
isLoading: repos.isLoading,
isError: repos.isError,
hasNextPage: repos.hasNextPage,
isFetchingNextPage: repos.isFetchingNextPage,
onLoadMore,
};
};

View File

@@ -1,16 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { Provider } from "#/types/settings";
export function useSearchRepositories(
query: string,
selectedProvider?: Provider | null,
) {
export function useSearchRepositories(query: string) {
return useQuery({
queryKey: ["repositories", "search", query, selectedProvider],
queryFn: () =>
OpenHands.searchGitRepositories(query, 3, selectedProvider || undefined),
enabled: !!query && !!selectedProvider,
queryKey: ["repositories", query],
queryFn: () => OpenHands.searchGitRepositories(query, 3),
enabled: !!query,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@@ -31,9 +31,6 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
EMAIL: apiSettings.email || "",
EMAIL_VERIFIED: apiSettings.email_verified,
MCP_CONFIG: apiSettings.mcp_config,
GIT_USER_NAME: apiSettings.git_user_name || DEFAULT_SETTINGS.GIT_USER_NAME,
GIT_USER_EMAIL:
apiSettings.git_user_email || DEFAULT_SETTINGS.GIT_USER_EMAIL,
IS_NEW_USER: false,
};
};

View File

@@ -1,41 +1,10 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useUserProviders } from "../use-user-providers";
import { Provider } from "#/types/settings";
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { shouldUseInstallationRepos } from "#/utils/utils";
export const useUserRepositories = (selectedProvider: Provider | null) => {
const { providers } = useUserProviders();
const { data: config } = useConfig();
const repos = useInfiniteQuery({
queryKey: ["repositories", providers || [], selectedProvider],
queryFn: async ({ pageParam }) =>
OpenHands.retrieveUserGitRepositories(selectedProvider!, pageParam, 30),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
enabled:
(providers || []).length > 0 &&
!!selectedProvider &&
!shouldUseInstallationRepos(selectedProvider, config?.APP_MODE),
export const useUserRepositories = () =>
useQuery({
queryKey: ["repositories"],
queryFn: OpenHands.retrieveUserGitRepositories,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
const onLoadMore = () => {
if (repos.hasNextPage && !repos.isFetchingNextPage) {
repos.fetchNextPage();
}
};
// Return the query result with the scroll ref
return {
data: repos.data,
isLoading: repos.isLoading,
isError: repos.isError,
hasNextPage: repos.hasNextPage,
isFetchingNextPage: repos.isFetchingNextPage,
onLoadMore,
};
};

View File

@@ -4,7 +4,6 @@ import { GetConfigResponse } from "#/api/open-hands.types";
interface UseAuthUrlConfig {
appMode: GetConfigResponse["APP_MODE"] | null;
identityProvider: string;
authUrl?: GetConfigResponse["AUTH_URL"];
}
export const useAuthUrl = (config: UseAuthUrlConfig) => {
@@ -12,7 +11,6 @@ export const useAuthUrl = (config: UseAuthUrlConfig) => {
return generateAuthUrl(
config.identityProvider,
new URL(window.location.href),
config.authUrl,
);
}

View File

@@ -19,25 +19,21 @@ export const useAutoLogin = () => {
const githubAuthUrl = useAuthUrl({
appMode: config?.APP_MODE || null,
identityProvider: "github",
authUrl: config?.AUTH_URL,
});
const gitlabAuthUrl = useAuthUrl({
appMode: config?.APP_MODE || null,
identityProvider: "gitlab",
authUrl: config?.AUTH_URL,
});
const bitbucketAuthUrl = useAuthUrl({
appMode: config?.APP_MODE || null,
identityProvider: "bitbucket",
authUrl: config?.AUTH_URL,
});
const enterpriseSsoUrl = useAuthUrl({
appMode: config?.APP_MODE || null,
identityProvider: "enterprise_sso",
authUrl: config?.AUTH_URL,
});
useEffect(() => {

View File

@@ -4,12 +4,10 @@ import { GetConfigResponse } from "#/api/open-hands.types";
interface UseGitHubAuthUrlConfig {
appMode: GetConfigResponse["APP_MODE"] | null;
gitHubClientId: GetConfigResponse["GITHUB_CLIENT_ID"] | null;
authUrl?: GetConfigResponse["AUTH_URL"];
}
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) =>
useAuthUrl({
appMode: config.appMode,
identityProvider: "github",
authUrl: config.authUrl,
});

View File

@@ -1,28 +0,0 @@
import React from "react";
import { useConfig } from "./query/use-config";
import { useIsAuthed } from "./query/use-is-authed";
import { useUserProviders } from "./use-user-providers";
/**
* Hook to determine if user-related features should be shown or enabled
* based on authentication status and provider configuration.
*
* @returns boolean indicating if user features should be shown
*/
export const useShouldShowUserFeatures = (): boolean => {
const { data: config } = useConfig();
const { data: isAuthed } = useIsAuthed();
const { providers } = useUserProviders();
return React.useMemo(() => {
if (!config?.APP_MODE || !isAuthed) return false;
// In OSS mode, only show user features if Git providers are configured
if (config.APP_MODE === "oss") {
return providers.length > 0;
}
// In non-OSS modes (saas), always show user features when authenticated
return true;
}, [config?.APP_MODE, isAuthed, providers.length]);
};

View File

@@ -44,6 +44,7 @@ export enum I18nKey {
SECRETS$SECRET_VALUE_REQUIRED = "SECRETS$SECRET_VALUE_REQUIRED",
SECRETS$ADD_SECRET = "SECRETS$ADD_SECRET",
SECRETS$EDIT_SECRET = "SECRETS$EDIT_SECRET",
SECRETS$NO_SECRETS_FOUND = "SECRETS$NO_SECRETS_FOUND",
SECRETS$ADD_NEW_SECRET = "SECRETS$ADD_NEW_SECRET",
SECRETS$CONFIRM_DELETE_KEY = "SECRETS$CONFIRM_DELETE_KEY",
SETTINGS$MCP_TITLE = "SETTINGS$MCP_TITLE",
@@ -77,7 +78,6 @@ export enum I18nKey {
HOME$OPENHANDS_DESCRIPTION = "HOME$OPENHANDS_DESCRIPTION",
HOME$NOT_SURE_HOW_TO_START = "HOME$NOT_SURE_HOW_TO_START",
HOME$CONNECT_TO_REPOSITORY = "HOME$CONNECT_TO_REPOSITORY",
HOME$CONNECT_TO_REPOSITORY_TOOLTIP = "HOME$CONNECT_TO_REPOSITORY_TOOLTIP",
HOME$LOADING = "HOME$LOADING",
HOME$LOADING_REPOSITORIES = "HOME$LOADING_REPOSITORIES",
HOME$FAILED_TO_LOAD_REPOSITORIES = "HOME$FAILED_TO_LOAD_REPOSITORIES",
@@ -236,7 +236,6 @@ export enum I18nKey {
SESSION$SESSION_HANDLING_ERROR_MESSAGE = "SESSION$SESSION_HANDLING_ERROR_MESSAGE",
SESSION$SESSION_CONNECTION_ERROR_MESSAGE = "SESSION$SESSION_CONNECTION_ERROR_MESSAGE",
SESSION$SOCKET_NOT_INITIALIZED_ERROR_MESSAGE = "SESSION$SOCKET_NOT_INITIALIZED_ERROR_MESSAGE",
SESSION$TIMEOUT_MESSAGE = "SESSION$TIMEOUT_MESSAGE",
EXPLORER$UPLOAD_ERROR_MESSAGE = "EXPLORER$UPLOAD_ERROR_MESSAGE",
EXPLORER$LABEL_DROP_FILES = "EXPLORER$LABEL_DROP_FILES",
EXPLORER$UPLOAD_SUCCESS_MESSAGE = "EXPLORER$UPLOAD_SUCCESS_MESSAGE",
@@ -584,8 +583,7 @@ export enum I18nKey {
BITBUCKET$TOKEN_LINK_TEXT = "BITBUCKET$TOKEN_LINK_TEXT",
BITBUCKET$INSTRUCTIONS_LINK_TEXT = "BITBUCKET$INSTRUCTIONS_LINK_TEXT",
GITLAB$OR_SEE = "GITLAB$OR_SEE",
AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_STOPPED = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_STOPPED",
AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_ERROR = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_ERROR",
AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED",
DIFF_VIEWER$LOADING = "DIFF_VIEWER$LOADING",
DIFF_VIEWER$GETTING_LATEST_CHANGES = "DIFF_VIEWER$GETTING_LATEST_CHANGES",
DIFF_VIEWER$NOT_A_GIT_REPO = "DIFF_VIEWER$NOT_A_GIT_REPO",
@@ -739,8 +737,6 @@ export enum I18nKey {
MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO = "MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO",
MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO = "MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO",
MICROAGENT_MANAGEMENT$UPDATE_MICROAGENT_MODAL_DESCRIPTION = "MICROAGENT_MANAGEMENT$UPDATE_MICROAGENT_MODAL_DESCRIPTION",
SETTINGS$GIT_USERNAME = "SETTINGS$GIT_USERNAME",
SETTINGS$GIT_EMAIL = "SETTINGS$GIT_EMAIL",
PROJECT_MANAGEMENT$TITLE = "PROJECT_MANAGEMENT$TITLE",
PROJECT_MANAGEMENT$VALIDATE_INTEGRATION_ERROR = "PROJECT_MANAGEMENT$VALIDATE_INTEGRATION_ERROR",
PROJECT_MANAGEMENT$LINK_BUTTON_LABEL = "PROJECT_MANAGEMENT$LINK_BUTTON_LABEL",

View File

@@ -703,6 +703,22 @@
"de": "Geheimnis bearbeiten",
"uk": "Редагувати секрет"
},
"SECRETS$NO_SECRETS_FOUND": {
"en": "No secrets found",
"ja": "シークレットが見つかりません",
"zh-CN": "未找到密钥",
"zh-TW": "未找到密鑰",
"ko-KR": "비밀을 찾을 수 없습니다",
"no": "Ingen hemmeligheter funnet",
"it": "Nessun segreto trovato",
"pt": "Nenhum segredo encontrado",
"es": "No se encontraron secretos",
"ar": "لم يتم العثور على أسرار",
"fr": "Aucun secret trouvé",
"tr": "Gizli bulunamadı",
"de": "Keine Geheimnisse gefunden",
"uk": "Секретів не знайдено"
},
"SECRETS$ADD_NEW_SECRET": {
"en": "Add a new secret",
"ja": "新しいシークレットを追加",
@@ -1231,22 +1247,6 @@
"de": "Mit einem Repository verbinden",
"uk": "Підключіть до репозиторій"
},
"HOME$CONNECT_TO_REPOSITORY_TOOLTIP": {
"en": "You can enter a public GitHub URL if you'd like to work from a public repo instead",
"ja": "代わりにパブリックリポジトリで作業したい場合は、パブリックGitHub URLを入力できます",
"zh-CN": "如果您想从公共仓库工作可以输入公共GitHub URL",
"zh-TW": "如果您想從公共儲存庫工作可以輸入公共GitHub URL",
"ko-KR": "대신 공개 저장소에서 작업하고 싶다면 공개 GitHub URL을 입력할 수 있습니다",
"no": "Du kan angi en offentlig GitHub URL hvis du vil jobbe fra et offentlig repo i stedet",
"it": "Puoi inserire un URL GitHub pubblico se preferisci lavorare da un repository pubblico",
"pt": "Você pode inserir um URL GitHub público se quiser trabalhar de um repositório público",
"es": "Puede ingresar una URL de GitHub pública si desea trabajar desde un repositorio público",
"ar": "يمكنك إدخال رابط GitHub عام إذا كنت تريد العمل من مستودع عام بدلاً من ذلك",
"fr": "Vous pouvez saisir une URL GitHub publique si vous souhaitez travailler à partir d'un dépôt public",
"tr": "Bunun yerine genel bir repo'dan çalışmak istiyorsanız genel bir GitHub URL'si girebilirsiniz",
"de": "Sie können eine öffentliche GitHub-URL eingeben, wenn Sie stattdessen von einem öffentlichen Repository arbeiten möchten",
"uk": "Ви можете ввести публічну GitHub URL, якщо хочете працювати з публічного репозиторію"
},
"HOME$LOADING": {
"en": "Loading...",
"ja": "読み込み中...",
@@ -3775,22 +3775,6 @@
"ja": "ソケットが初期化されていません",
"uk": "Сокет не ініціалізовано"
},
"SESSION$TIMEOUT_MESSAGE": {
"en": "Session Timeout!",
"zh-CN": "会话超时!",
"de": "Sitzung abgelaufen!",
"zh-TW": "會話超時!",
"es": "¡Tiempo de sesión agotado!",
"fr": "Expiration de la session !",
"it": "Timeout della sessione!",
"pt": "Tempo limite da sessão!",
"ko-KR": "세션 시간 초과!",
"ar": "انتهت مهلة الجلسة!",
"tr": "Oturum Zaman Aşımı!",
"no": "Økten har tidsavbrutt!",
"ja": "セッションタイムアウト!",
"uk": "Час сеансу минув!"
},
"EXPLORER$UPLOAD_ERROR_MESSAGE": {
"en": "Error uploading file",
"zh-CN": "上传时出错",
@@ -9343,37 +9327,21 @@
"de": "oder siehe",
"uk": "або перегляньте"
},
"AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_STOPPED": {
"en": "Stop button pressed. The action has not been executed.",
"ja": "停止ボタンが押されました。アクションは実行されていません。",
"zh-CN": "按下了停止按钮。操作尚未执行。",
"zh-TW": "按下了停止按鈕。操作尚未執行。",
"ko-KR": "중지 버튼이 눌렸습니다. 작업이 실행되지 않았습니다.",
"no": "Stoppknappen ble trykket. Handlingen har ikke blitt utført.",
"it": "Pulsante di arresto premuto. L'azione non è stata eseguita.",
"pt": "Botão de parar pressionado. A ação não foi executada.",
"es": "Botón de detener presionado. La acción no se ha ejecutado.",
"ar": "تم الضغط على زر التوقف. لم يتم تنفيذ الإجراء.",
"fr": "Bouton d'arrêt appuyé. L'action n'a pas été exécutée.",
"tr": "Durdurma düğmesine basıldı. Eylem yürütülmedi.",
"de": "Stopp-Taste gedrückt. Die Aktion wurde nicht ausgeführt.",
"uk": "Натиснуто кнопку зупинки. Дію не виконано."
},
"AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_ERROR": {
"en": "The action has not been executed due to a runtime error. The runtime system may have crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.",
"ja": "ランタイムエラーによりアクションは実行されていません。リソース制約によりランタイムシステムがクラッシュして再起動した可能性があります。以前に確立されたシステム状態、依存関係、または環境変数は失われている可能性があります。",
"zh-CN": "由于运行时错误,该操作尚未执行。运行时系统可能由于资源限制而崩溃并重新启动。任何先前建立的系统状态、依赖项或环境变量可能已丢失。",
"zh-TW": "由於運行時錯誤,該操作尚未執行。運行時系統可能由於資源限制而崩潰並重新啟動。任何先前建立的系統狀態、依賴項或環境變數可能已丟失。",
"ko-KR": "런타임 오류로 인해 작업이 실행되지 않았습니다. 리소스 제약으로 인해 런타임 시스템이 충돌하고 재시작되었을 수 있습니다. 이전에 설정된 시스템 상태, 종속성 또는 환경 변수가 손실되었을 수 있습니다.",
"no": "Handlingen har ikke blitt utført på grunn av en kjøretidsfeil. Kjøretidssystemet kan ha krasjet og startet på nytt på grunn av ressursbegrensninger. Enhver tidligere etablert systemtilstand, avhengigheter eller miljøvariabler kan ha gått tapt.",
"it": "L'azione non è stata eseguita a causa di un errore di runtime. Il sistema di runtime potrebbe essere andato in crash e riavviato a causa di vincoli di risorse. Qualsiasi stato di sistema, dipendenza o variabile d'ambiente precedentemente stabilito potrebbe essere andato perso.",
"pt": "A ação não foi executada devido a um erro de tempo de execução. O sistema de tempo de execução pode ter travado e reiniciado devido a restrições de recursos. Qualquer estado do sistema, dependências ou variáveis de ambiente estabelecidos anteriormente podem ter sido perdidos.",
"es": "La acción no se ha ejecutado debido a un error de tiempo de ejecución. El sistema de tiempo de ejecución puede haberse bloqueado y reiniciado debido a restricciones de recursos. Cualquier estado del sistema, dependencias o variables de entorno establecidos previamente pueden haberse perdido.",
"ar": "لم يتم تنفيذ الإجراء بسبب خطأ في وقت التشغيل. قد يكون نظام وقت التشغيل قد تعطل وأعيد تشغيله بسبب قيود الموارد. قد تكون أي حالة نظام أو تبعيات أو متغيرات بيئية تم إنشاؤها مسبقًا قد فُقدت.",
"fr": "L'action n'a pas été exécutée en raison d'une erreur d'exécution. Le système d'exécution peut s'être planté et avoir redémarré en raison de contraintes de ressources. Tout état du système, dépendances ou variables d'environnement précédemment établis peuvent avoir été perdus.",
"tr": "Çalışma zamanı hatası nedeniyle eylem yürütülmedi. Çalışma zamanı sistemi kaynak kısıtlamaları nedeniyle çökmüş ve yeniden başlamış olabilir. Daha önce kurulmuş olan herhangi bir sistem durumu, bağımlılıklar veya ortam değişkenleri kaybolmuş olabilir.",
"de": "Die Aktion wurde aufgrund eines Laufzeitfehlers nicht ausgeführt. Das Laufzeitsystem ist möglicherweise aufgrund von Ressourcenbeschränkungen abgestürzt und neu gestartet worden. Alle zuvor eingerichteten Systemzustände, Abhängigkeiten oder Umgebungsvariablen sind möglicherweise verloren gegangen.",
"uk": "Дію не виконано через помилку виконання. Система виконання могла зазнати збою та перезапуститися через обмеження ресурсів. Можливо, було втрачено будь-який раніше встановлений стан системи, залежності або змінні середовища."
"AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED": {
"en": "The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.",
"ja": "アクションは実行されていません。これはユーザーが停止ボタンを押したか、リソース制約によりランタイムシステムがクラッシュして再起動したことが原因かもしれません。以前に確立されたシステム状態、依存関係、または環境変数は失われている可能性があります。",
"zh-CN": "该操作尚未执行。这可能是因为用户按下了停止按钮,或者因为运行时系统由于资源限制而崩溃并重新启动。任何先前建立的系统状态、依赖项或环境变量可能已丢失。",
"zh-TW": "該操作尚未執行。這可能是因為用戶按下了停止按鈕,或者因為運行時系統由於資源限制而崩潰並重新啟動。任何先前建立的系統狀態、依賴項或環境變數可能已丟失。",
"ko-KR": "작업이 실행되지 않았습니다. 이는 사용자가 중지 버튼을 눌렀거나 리소스 제약으로 인해 런타임 시스템이 충돌하고 재시작되었기 때문일 수 있습니다. 이전에 설정된 시스템 상태, 종속성 또는 환경 변수가 손실되었을 수 있습니다.",
"no": "Handlingen har ikke blitt utført. Dette kan ha skjedd fordi brukeren trykket på stoppknappen, eller fordi kjøretidssystemet krasjet og startet på nytt på grunn av ressursbegrensninger. Enhver tidligere etablert systemtilstand, avhengigheter eller miljøvariabler kan ha gått tapt.",
"it": "L'azione non è stata eseguita. Ciò potrebbe essere accaduto perché l'utente ha premuto il pulsante di arresto, o perché il sistema di runtime si è arrestato in modo anomalo e riavviato a causa di vincoli di risorse. Qualsiasi stato di sistema, dipendenza o variabile d'ambiente precedentemente stabilito potrebbe essere andato perso.",
"pt": "A ação não foi executada. Isso pode ter ocorrido porque o usuário pressionou o botão de parar, ou porque o sistema de tempo de execução travou e reiniciou devido a restrições de recursos. Qualquer estado do sistema, dependências ou variáveis de ambiente estabelecidos anteriormente podem ter sido perdidos.",
"es": "La acción no se ha ejecutado. Esto puede haber ocurrido porque el usuario presionó el botón de detener, o porque el sistema de tiempo de ejecución se bloqueó y reinició debido a restricciones de recursos. Cualquier estado del sistema, dependencias o variables de entorno establecidos previamente pueden haberse perdido.",
"ar": "لم يتم تنفيذ الإجراء. قد يكون هذا حدث لأن المستخدم ضغط على زر التوقف، أو لأن نظام التشغيل تعطل وأعيد تشغيله بسبب قيود الموارد. قد تكون أي حالة نظام أو تبعيات أو متغيرات بيئية تم إنشاؤها مسبقًا قد فُقدت.",
"fr": "L'action n'a pas été exécutée. Cela peut s'être produit parce que l'utilisateur a appuyé sur le bouton d'arrêt, ou parce que le système d'exécution s'est planté et a redémarré en raison de contraintes de ressources. Tout état du système, dépendances ou variables d'environnement précédemment établis peuvent avoir été perdus.",
"tr": "Eylem yürütülmedi. Bu, kullanıcının durdurma düğmesine basması veya çalışma zamanı sisteminin kaynak kısıtlamaları nedeniyle çökmesi ve yeniden başlaması nedeniyle olmuş olabilir. Daha önce kurulmuş olan herhangi bir sistem durumu, bağımlılıklar veya ortam değişkenleri kaybolmuş olabilir.",
"de": "Die Aktion wurde nicht ausgeführt. Dies kann passiert sein, weil der Benutzer die Stopp-Taste gedrückt hat oder weil das Laufzeitsystem aufgrund von Ressourcenbeschränkungen abgestürzt und neu gestartet wurde. Alle zuvor eingerichteten Systemzustände, Abhängigkeiten oder Umgebungsvariablen sind möglicherweise verloren gegangen.",
"uk": "Дію не виконано. Можливо, це сталося через натискання користувачем кнопки зупинки або через збій та перезапуск системи виконання через обмеження ресурсів. Можливо, було втрачено будь-який раніше встановлений стан системи, залежності або змінні середовища."
},
"DIFF_VIEWER$LOADING": {
"en": "Loading changes...",
@@ -11823,38 +11791,6 @@
"de": "OpenHands aktualisiert den Microagenten basierend auf Ihren Anweisungen.",
"uk": "OpenHands оновить мікроагента відповідно до ваших інструкцій."
},
"SETTINGS$GIT_USERNAME": {
"en": "Git Username",
"ja": "Gitユーザー名",
"zh-CN": "Git用户名",
"zh-TW": "Git使用者名稱",
"ko-KR": "Git 사용자 이름",
"no": "Git-brukernavn",
"it": "Nome utente Git",
"pt": "Nome de usuário Git",
"es": "Nombre de usuario Git",
"ar": "اسم مستخدم Git",
"fr": "Nom d'utilisateur Git",
"tr": "Git Kullanıcı Adı",
"de": "Git-Benutzername",
"uk": "Ім'я користувача Git"
},
"SETTINGS$GIT_EMAIL": {
"en": "Git Email",
"ja": "Gitメールアドレス",
"zh-CN": "Git电子邮件",
"zh-TW": "Git電子郵件",
"ko-KR": "Git 이메일",
"no": "Git-e-post",
"it": "Email Git",
"pt": "Email Git",
"es": "Correo electrónico Git",
"ar": "بريد Git الإلكتروني",
"fr": "Email Git",
"tr": "Git E-posta",
"de": "Git-E-Mail",
"uk": "Електронна пошта Git"
},
"PROJECT_MANAGEMENT$TITLE": {
"en": "Project Management",
"ja": "プロジェクト管理",

View File

@@ -1,157 +0,0 @@
import { delay, http, HttpResponse } from "msw";
import { GitRepository } from "#/types/git";
import { Provider } from "#/types/settings";
// Generate a list of mock repositories with realistic data
const generateMockRepositories = (
count: number,
provider: Provider,
): GitRepository[] =>
Array.from({ length: count }, (_, i) => ({
id: `${i + 1}`,
full_name: `user/repo-${i + 1}`,
git_provider: provider,
is_public: Math.random() > 0.3, // 70% chance of being public
stargazers_count: Math.floor(Math.random() * 1000),
pushed_at: new Date(
Date.now() - Math.random() * 90 * 24 * 60 * 60 * 1000,
).toISOString(), // Last 90 days
owner_type: Math.random() > 0.7 ? "organization" : "user", // 30% chance of being organization
}));
// Mock repositories for each provider
const MOCK_REPOSITORIES = {
github: generateMockRepositories(120, "github"),
gitlab: generateMockRepositories(120, "gitlab"),
bitbucket: generateMockRepositories(120, "bitbucket"),
};
export const GIT_REPOSITORY_HANDLERS = [
http.get("/api/user/repositories", async ({ request }) => {
await delay(500); // Simulate network delay
const url = new URL(request.url);
const selectedProvider = url.searchParams.get("selected_provider");
const page = parseInt(url.searchParams.get("page") || "1", 10);
const perPage = parseInt(url.searchParams.get("per_page") || "30", 10);
const sort = url.searchParams.get("sort") || "pushed";
const installationId = url.searchParams.get("installation_id");
// Simulate authentication error if no provider token
if (!selectedProvider) {
return HttpResponse.json(
"Git provider token required. (such as GitHub).",
{ status: 401 },
);
}
// Get repositories for the selected provider
const repositories =
MOCK_REPOSITORIES[selectedProvider as keyof typeof MOCK_REPOSITORIES] ||
[];
// Sort repositories based on the sort parameter
let sortedRepos = [...repositories];
if (sort === "pushed") {
sortedRepos.sort(
(a, b) =>
new Date(b.pushed_at!).getTime() - new Date(a.pushed_at!).getTime(),
);
} else if (sort === "stars") {
sortedRepos.sort(
(a, b) => (b.stargazers_count || 0) - (a.stargazers_count || 0),
);
}
// Handle installation filtering (for GitHub Apps)
if (installationId && selectedProvider === "github") {
// Simulate filtering by installation - in real API this would filter by access
const installationIndex = parseInt(installationId, 10) || 0;
const startRepo = installationIndex * 20; // Each installation has ~20 repos
sortedRepos = sortedRepos.slice(startRepo, startRepo + 20);
}
// Calculate pagination
const startIndex = (page - 1) * perPage;
const endIndex = startIndex + perPage;
const paginatedRepos = sortedRepos.slice(startIndex, endIndex);
const hasNextPage = endIndex < sortedRepos.length;
const hasPrevPage = page > 1;
const totalPages = Math.ceil(sortedRepos.length / perPage);
// Generate GitHub-style link header for pagination
let linkHeader = "";
if (hasNextPage || hasPrevPage) {
const links = [];
if (hasPrevPage) {
links.push(
`</api/user/repositories?page=${page - 1}&per_page=${perPage}>; rel="prev"`,
);
}
if (hasNextPage) {
links.push(
`</api/user/repositories?page=${page + 1}&per_page=${perPage}>; rel="next"`,
);
}
links.push(
`</api/user/repositories?page=${totalPages}&per_page=${perPage}>; rel="last"`,
);
links.push(
`</api/user/repositories?page=1&per_page=${perPage}>; rel="first"`,
);
linkHeader = links.join(", ");
}
// Add link_header to the first repository if pagination info exists
const responseRepos = [...paginatedRepos];
if (responseRepos.length > 0 && linkHeader) {
responseRepos[0] = { ...responseRepos[0], link_header: linkHeader };
}
// Return response as direct Repository array (matching real API)
return HttpResponse.json(responseRepos);
}),
http.get("/api/user/search/repositories", async ({ request }) => {
await delay(300); // Simulate network delay
const url = new URL(request.url);
const query = url.searchParams.get("query") || "";
const selectedProvider = url.searchParams.get("selected_provider");
const perPage = parseInt(url.searchParams.get("per_page") || "5", 10);
const sort = url.searchParams.get("sort") || "stars";
const order = url.searchParams.get("order") || "desc";
// Simulate authentication error if no provider token
if (!selectedProvider) {
return HttpResponse.json("Git provider token required.", {
status: 401,
});
}
// Get repositories for the selected provider
const repositories =
MOCK_REPOSITORIES[selectedProvider as keyof typeof MOCK_REPOSITORIES] ||
[];
// Filter repositories by search query
const filteredRepos = repositories.filter((repo) =>
repo.full_name.toLowerCase().includes(query.toLowerCase()),
);
// Sort repositories
const sortedRepos = [...filteredRepos];
if (sort === "stars") {
sortedRepos.sort((a, b) => {
const aStars = a.stargazers_count || 0;
const bStars = b.stargazers_count || 0;
return order === "desc" ? bStars - aStars : aStars - bStars;
});
}
// Limit results
const limitedRepos = sortedRepos.slice(0, perPage);
return HttpResponse.json(limitedRepos);
}),
];

View File

@@ -8,10 +8,9 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
import { GitUser } from "#/types/git";
import { GitRepository, GitUser } from "#/types/git";
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
import { SECRETS_HANDLERS } from "./secrets-handlers";
import { GIT_REPOSITORY_HANDLERS } from "./git-repository-handlers";
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
@@ -25,7 +24,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
remote_runtime_resource_factor:
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
provider_tokens_set: { github: null, gitlab: null, bitbucket: null },
provider_tokens_set: DEFAULT_SETTINGS.PROVIDER_TOKENS_SET,
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
enable_proactive_conversation_starters:
@@ -112,7 +111,6 @@ const openHandsHandlers = [
"gpt-4o-mini",
"anthropic/claude-3.5",
"anthropic/claude-sonnet-4-20250514",
"openhands/claude-sonnet-4-20250514",
"sambanova/Meta-Llama-3.1-8B-Instruct",
]),
),
@@ -140,8 +138,25 @@ export const handlers = [
...FILE_SERVICE_HANDLERS,
...TASK_SUGGESTIONS_HANDLERS,
...SECRETS_HANDLERS,
...GIT_REPOSITORY_HANDLERS,
...openHandsHandlers,
http.get("/api/user/repositories", () => {
const data: GitRepository[] = [
{
id: "1",
full_name: "octocat/hello-world",
git_provider: "github",
is_public: true,
},
{
id: "2",
full_name: "octocat/earth",
git_provider: "github",
is_public: true,
},
];
return HttpResponse.json(data);
}),
http.get("/api/user/info", () => {
const user: GitUser = {
id: "1",
@@ -190,6 +205,9 @@ export const handlers = [
if (!settings) return HttpResponse.json(null, { status: 404 });
if (Object.keys(settings.provider_tokens_set).length > 0)
settings.provider_tokens_set = {};
return HttpResponse.json(settings);
}),
http.post("/api/settings", async ({ request }) => {
@@ -197,7 +215,18 @@ export const handlers = [
const body = await request.json();
if (body) {
MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
let newSettings: Partial<PostApiSettings> = {};
if (typeof body === "object") {
newSettings = { ...body };
}
const fullSettings = {
...MOCK_DEFAULT_USER_SETTINGS,
...MOCK_USER_PREFERENCES.settings,
...newSettings,
};
MOCK_USER_PREFERENCES.settings = fullSettings;
return HttpResponse.json(null, { status: 200 });
}

View File

@@ -40,10 +40,6 @@ function AppSettingsScreen() {
] = React.useState(false);
const [maxBudgetPerTaskHasChanged, setMaxBudgetPerTaskHasChanged] =
React.useState(false);
const [gitUserNameHasChanged, setGitUserNameHasChanged] =
React.useState(false);
const [gitUserEmailHasChanged, setGitUserEmailHasChanged] =
React.useState(false);
const formAction = (formData: FormData) => {
const languageLabel = formData.get("language-input")?.toString();
@@ -66,13 +62,6 @@ function AppSettingsScreen() {
?.toString();
const maxBudgetPerTask = parseMaxBudgetPerTask(maxBudgetPerTaskValue || "");
const gitUserName =
formData.get("git-user-name-input")?.toString() ||
DEFAULT_SETTINGS.GIT_USER_NAME;
const gitUserEmail =
formData.get("git-user-email-input")?.toString() ||
DEFAULT_SETTINGS.GIT_USER_EMAIL;
saveSettings(
{
LANGUAGE: language,
@@ -80,8 +69,6 @@ function AppSettingsScreen() {
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
ENABLE_PROACTIVE_CONVERSATION_STARTERS: enableProactiveConversations,
MAX_BUDGET_PER_TASK: maxBudgetPerTask,
GIT_USER_NAME: gitUserName,
GIT_USER_EMAIL: gitUserEmail,
},
{
onSuccess: () => {
@@ -98,8 +85,6 @@ function AppSettingsScreen() {
setSoundNotificationsSwitchHasChanged(false);
setProactiveConversationsSwitchHasChanged(false);
setMaxBudgetPerTaskHasChanged(false);
setGitUserNameHasChanged(false);
setGitUserEmailHasChanged(false);
},
},
);
@@ -142,24 +127,12 @@ function AppSettingsScreen() {
setMaxBudgetPerTaskHasChanged(newValue !== currentValue);
};
const checkIfGitUserNameHasChanged = (value: string) => {
const currentValue = settings?.GIT_USER_NAME;
setGitUserNameHasChanged(value !== currentValue);
};
const checkIfGitUserEmailHasChanged = (value: string) => {
const currentValue = settings?.GIT_USER_EMAIL;
setGitUserEmailHasChanged(value !== currentValue);
};
const formIsClean =
!languageInputHasChanged &&
!analyticsSwitchHasChanged &&
!soundNotificationsSwitchHasChanged &&
!proactiveConversationsSwitchHasChanged &&
!maxBudgetPerTaskHasChanged &&
!gitUserNameHasChanged &&
!gitUserEmailHasChanged;
!maxBudgetPerTaskHasChanged;
const shouldBeLoading = !settings || isLoading || isPending;
@@ -221,34 +194,6 @@ function AppSettingsScreen() {
step={1}
className="w-full max-w-[680px]" // Match the width of the language field
/>
<div className="border-t border-t-tertiary pt-6 mt-2">
<h3 className="text-lg font-medium mb-4">
{t(I18nKey.SETTINGS$GIT_SETTINGS)}
</h3>
<div className="flex flex-col gap-6">
<SettingsInput
testId="git-user-name-input"
name="git-user-name-input"
type="text"
label={t(I18nKey.SETTINGS$GIT_USERNAME)}
defaultValue={settings.GIT_USER_NAME || ""}
onChange={checkIfGitUserNameHasChanged}
placeholder="Username for git commits"
className="w-full max-w-[680px]"
/>
<SettingsInput
testId="git-user-email-input"
name="git-user-email-input"
type="email"
label={t(I18nKey.SETTINGS$GIT_EMAIL)}
defaultValue={settings.GIT_USER_EMAIL || ""}
onChange={checkIfGitUserEmailHasChanged}
placeholder="Email for git commits"
className="w-full max-w-[680px]"
/>
</div>
</div>
</div>
)}

View File

@@ -1,3 +1,5 @@
import { redirect } from "react-router";
import { Route } from "./+types/settings";
import { queryClient } from "#/query-client-config";
import { GetConfigResponse } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
@@ -5,13 +7,23 @@ import { MicroagentManagementContent } from "#/components/features/microagent-ma
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
import { EventHandler } from "#/wrapper/event-handler";
export const clientLoader = async () => {
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
const url = new URL(request.url);
const { pathname } = url;
let config = queryClient.getQueryData<GetConfigResponse>(["config"]);
if (!config) {
config = await OpenHands.getConfig();
queryClient.setQueryData<GetConfigResponse>(["config"], config);
}
const shouldHideMicroagentManagement =
config?.FEATURE_FLAGS.HIDE_MICROAGENT_MANAGEMENT;
if (shouldHideMicroagentManagement && pathname === "/microagent-management") {
return redirect("/");
}
return null;
};

View File

@@ -81,7 +81,6 @@ export default function MainApp() {
const gitHubAuthUrl = useGitHubAuthUrl({
appMode: config.data?.APP_MODE || null,
gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
authUrl: config.data?.AUTH_URL,
});
// When on TOS page, we don't use the GitHub auth URL
@@ -220,7 +219,6 @@ export default function MainApp() {
githubAuthUrl={effectiveGitHubAuthUrl}
appMode={config.data?.APP_MODE}
providersConfigured={config.data?.PROVIDERS_CONFIGURED}
authUrl={config.data?.AUTH_URL}
/>
)}
{renderReAuthModal && <ReauthModal />}

View File

@@ -96,6 +96,10 @@ function SecretsSettingsScreen() {
</Link>
)}
{secrets?.length === 0 && view === "list" && (
<p data-testid="no-secrets-message">{t("SECRETS$NO_SECRETS_FOUND")}</p>
)}
{!shouldRenderConnectToGitButton && view === "list" && (
<BrandButton
testId="add-secret-button"

View File

@@ -3,7 +3,7 @@ import { Settings } from "#/types/settings";
export const LATEST_SETTINGS_VERSION = 5;
export const DEFAULT_SETTINGS: Settings = {
LLM_MODEL: "openhands/claude-sonnet-4-20250514",
LLM_MODEL: "anthropic/claude-sonnet-4-20250514",
LLM_BASE_URL: "",
AGENT: "CodeActAgent",
LANGUAGE: "en",
@@ -26,8 +26,6 @@ export const DEFAULT_SETTINGS: Settings = {
sse_servers: [],
stdio_servers: [],
},
GIT_USER_NAME: "openhands",
GIT_USER_EMAIL: "openhands@all-hands.dev",
};
/**

View File

@@ -50,8 +50,6 @@ export type Settings = {
MAX_BUDGET_PER_TASK: number | null;
EMAIL?: string;
EMAIL_VERIFIED?: boolean;
GIT_USER_NAME?: string;
GIT_USER_EMAIL?: string;
};
export type ApiSettings = {
@@ -78,8 +76,6 @@ export type ApiSettings = {
};
email?: string;
email_verified?: boolean;
git_user_name?: string;
git_user_email?: string;
};
export type PostSettings = Settings & {

View File

@@ -4,41 +4,23 @@
* @param requestUrl The URL of the request
* @returns The URL to redirect to for OAuth
*/
export const generateAuthUrl = (
identityProvider: string,
requestUrl: URL,
authUrl?: string,
) => {
export const generateAuthUrl = (identityProvider: string, requestUrl: URL) => {
// Use HTTPS protocol unless the host is localhost
const protocol =
requestUrl.hostname === "localhost" ? requestUrl.protocol : "https:";
const redirectUri = `${protocol}//${requestUrl.host}/oauth/keycloak/callback`;
let authUrl = requestUrl.hostname
.replace(/(^|\.)staging\.all-hands\.dev$/, "$1auth.staging.all-hands.dev")
.replace(/(^|\.)app\.all-hands\.dev$/, "auth.app.all-hands.dev")
.replace(/(^|\.)localhost$/, "auth.staging.all-hands.dev");
let finalAuthUrl: string;
if (authUrl) {
// Ensure https:// is prepended and remove any accidental duplicate slashes
finalAuthUrl = `https://${authUrl.replace(/^https?:\/\//, "")}`;
} else {
finalAuthUrl = requestUrl.hostname
.replace(/(^|\.)staging\.all-hands\.dev$/, "$1auth.staging.all-hands.dev")
.replace(/(^|\.)app\.all-hands\.dev$/, "auth.app.all-hands.dev")
.replace(/(^|\.)localhost$/, "auth.staging.all-hands.dev");
// If no replacements matched, prepend "auth." (excluding localhost)
if (
finalAuthUrl === requestUrl.hostname &&
requestUrl.hostname !== "localhost"
) {
finalAuthUrl = `auth.${requestUrl.hostname}`;
}
finalAuthUrl = `https://${finalAuthUrl}`;
// If no replacements matched, prepend "auth." (excluding localhost)
if (authUrl === requestUrl.hostname && requestUrl.hostname !== "localhost") {
authUrl = `auth.${requestUrl.hostname}`;
}
const scope = "openid email profile"; // OAuth scope - not user-facing
const separator = requestUrl.search ? "&" : "?";
const cleanHref = requestUrl.href.replace(/\/$/, "");
const state = `${cleanHref}${separator}login_method=${identityProvider}`;
return `${finalAuthUrl}/realms/allhands/protocol/openid-connect/auth?client_id=allhands&kc_idp_hint=${identityProvider}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}`;
return `https://${authUrl}/realms/allhands/protocol/openid-connect/auth?client_id=allhands&kc_idp_hint=${identityProvider}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}`;
};

View File

@@ -104,24 +104,6 @@ export const formatTimestamp = (timestamp: string) =>
second: "2-digit",
});
export const shouldUseInstallationRepos = (
provider: Provider,
app_mode: "saas" | "oss" | undefined,
) => {
if (!provider) return false;
switch (provider) {
case "bitbucket":
return true;
case "gitlab":
return false;
case "github":
return app_mode === "saas";
default:
return false;
}
};
export const getGitProviderBaseUrl = (gitProvider: Provider): string => {
switch (gitProvider) {
case "github":

View File

@@ -205,11 +205,20 @@ async def modify_llm_settings_basic(
provider = verified_providers[choice_index]
else:
# User selected "Select another provider" - use manual selection
# Define a validator function that prints an error message
def provider_validator(x):
is_valid = x in organized_models
if not is_valid:
print_formatted_text(
HTML('<grey>Invalid provider selected: {}</grey>'.format(x))
)
return is_valid
provider = await get_validated_input(
session,
'(Step 1/3) Select LLM Provider (TAB for options, CTRL-c to cancel): ',
completer=provider_completer,
validator=lambda x: x in organized_models,
validator=provider_validator,
error_message='Invalid provider selected',
)

View File

@@ -82,12 +82,8 @@ from openhands.storage.files import FileStore
TRAFFIC_CONTROL_REMINDER = (
"Please click on resume button if you'd like to continue, or start a new task."
)
ERROR_ACTION_NOT_EXECUTED_STOPPED_ID = 'AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_STOPPED'
ERROR_ACTION_NOT_EXECUTED_ERROR_ID = 'AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_ERROR'
ERROR_ACTION_NOT_EXECUTED_STOPPED = (
'Stop button pressed. The action has not been executed.'
)
ERROR_ACTION_NOT_EXECUTED_ERROR = 'The action has not been executed due to a runtime error. The runtime system may have crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.'
ERROR_ACTION_NOT_EXECUTED_ID = 'AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED'
ERROR_ACTION_NOT_EXECUTED = 'The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.'
class AgentController:
@@ -556,17 +552,9 @@ class AgentController:
# make a new ErrorObservation with the tool call metadata
if not found_observation:
# Use different messages and IDs based on whether the agent was stopped by user or due to error
if self.state.agent_state == AgentState.STOPPED:
error_content = ERROR_ACTION_NOT_EXECUTED_STOPPED
error_id = ERROR_ACTION_NOT_EXECUTED_STOPPED_ID
else: # AgentState.ERROR
error_content = ERROR_ACTION_NOT_EXECUTED_ERROR
error_id = ERROR_ACTION_NOT_EXECUTED_ERROR_ID
obs = ErrorObservation(
content=error_content,
error_id=error_id,
content=ERROR_ACTION_NOT_EXECUTED,
error_id=ERROR_ACTION_NOT_EXECUTED_ID,
)
obs.tool_call_metadata = self._pending_action.tool_call_metadata
obs._cause = self._pending_action.id # type: ignore[attr-defined]
@@ -592,17 +580,14 @@ class AgentController:
if new_state == self.state.agent_state:
return
# Store old state for control limits check
old_state = self.state.agent_state
# Update agent state BEFORE calling _reset() so _reset() sees the correct state
self.state.agent_state = new_state
if new_state in (AgentState.STOPPED, AgentState.ERROR):
self._reset()
# User is allowing to check control limits and expand them if applicable
if old_state == AgentState.ERROR and new_state == AgentState.RUNNING:
if (
self.state.agent_state == AgentState.ERROR
and new_state == AgentState.RUNNING
):
self.state_tracker.maybe_increase_control_flags_limits(self.headless_mode)
if self._pending_action is not None and (
@@ -618,6 +603,8 @@ class AgentController:
self._pending_action._id = None # type: ignore[attr-defined]
self.event_stream.add_event(self._pending_action, EventSource.AGENT)
self.state.agent_state = new_state
# Create observation with reason field if it's an error state
reason = ''
if new_state == AgentState.ERROR:

View File

@@ -74,7 +74,7 @@ class MCPStdioServerConfig(BaseModel):
args: list[str] = Field(default_factory=list)
env: dict[str, str] = Field(default_factory=dict)
@field_validator('name', mode='before')
@field_validator('name')
@classmethod
def validate_server_name(cls, v: str) -> str:
"""Validate server name for stdio MCP servers."""
@@ -91,7 +91,7 @@ class MCPStdioServerConfig(BaseModel):
return v
@field_validator('command', mode='before')
@field_validator('command')
@classmethod
def validate_command(cls, v: str) -> str:
"""Validate command for stdio MCP servers."""
@@ -114,7 +114,6 @@ class MCPStdioServerConfig(BaseModel):
"""Parse arguments from string or return list as-is.
Supports shell-like argument parsing using shlex.split().
Examples:
- "-y mcp-remote https://example.com"
- '--config "path with spaces" --debug'
@@ -190,7 +189,7 @@ class MCPSHTTPServerConfig(BaseModel):
url: str
api_key: str | None = None
@field_validator('url', mode='before')
@field_validator('url')
@classmethod
def validate_url(cls, v: str) -> str:
"""Validate URL format for MCP servers."""
@@ -203,12 +202,12 @@ class MCPConfig(BaseModel):
Attributes:
sse_servers: List of MCP SSE server configs
stdio_servers: List of MCP stdio server configs. These servers will be added to the MCP Router running inside runtime container.
shttp_servers: List of MCP HTTP server configs.
"""
sse_servers: list[MCPSSEServerConfig] = Field(default_factory=list)
stdio_servers: list[MCPStdioServerConfig] = Field(default_factory=list)
shttp_servers: list[MCPSHTTPServerConfig] = Field(default_factory=list)
model_config = ConfigDict(extra='forbid')
@staticmethod
@@ -253,7 +252,8 @@ class MCPConfig(BaseModel):
@classmethod
def from_toml_section(cls, data: dict) -> dict[str, 'MCPConfig']:
"""Create a mapping of MCPConfig instances from a toml dictionary representing the [mcp] section.
"""
Create a mapping of MCPConfig instances from a toml dictionary representing the [mcp] section.
The configuration is built from all keys in data.
@@ -306,7 +306,7 @@ class MCPConfig(BaseModel):
class OpenHandsMCPConfig:
@staticmethod
def add_search_engine(app_config: 'OpenHandsConfig') -> MCPStdioServerConfig | None:
"""Add search engine to the MCP config."""
"""Add search engine to the MCP config"""
if (
app_config.search_api_key
and app_config.search_api_key.get_secret_value().startswith('tvly-')
@@ -327,23 +327,21 @@ class OpenHandsMCPConfig:
def create_default_mcp_server_config(
host: str, config: 'OpenHandsConfig', user_id: str | None = None
) -> tuple[MCPSHTTPServerConfig | None, list[MCPStdioServerConfig]]:
"""Create a default MCP server configuration.
"""
Create a default MCP server configuration.
Args:
host: Host string
config: OpenHandsConfig
user_id: Optional user ID for the MCP server
Returns:
tuple[MCPSHTTPServerConfig | None, list[MCPStdioServerConfig]]: A tuple containing the default SHTTP server configuration (or None) and a list of MCP stdio server configurations
"""
stdio_servers = []
search_engine_stdio_server = OpenHandsMCPConfig.add_search_engine(config)
if search_engine_stdio_server:
stdio_servers.append(search_engine_stdio_server)
shttp_servers = MCPSHTTPServerConfig(url=f'http://{host}/mcp/mcp', api_key=None)
return shttp_servers, stdio_servers

View File

@@ -5,7 +5,7 @@ import platform
import sys
from ast import literal_eval
from types import UnionType
from typing import Any, MutableMapping, get_args, get_origin, get_type_hints
from typing import MutableMapping, get_args, get_origin, get_type_hints
from uuid import uuid4
import toml
@@ -75,7 +75,6 @@ def load_from_env(
# e.g. LLM_BASE_URL
env_var_name = (prefix + field_name).upper()
cast_value: Any
if isinstance(field_value, BaseModel):
set_attr_from_env(field_value, prefix=field_name + '_')
@@ -95,7 +94,7 @@ def load_from_env(
# Attempt to cast the env var to type hinted in the dataclass
if field_type is bool:
cast_value = str(value).lower() in ['true', '1']
# parse dicts and lists like SANDBOX_RUNTIME_STARTUP_ENV_VARS and SANDBOX_RUNTIME_EXTRA_BUILD_ARGS
# parse dicts and lists like SANDBOX_RUNTIME_STARTUP_ENV_VARS and SANDBOX_RUNTIME_EXTRA_BUILD_ARGS
elif (
get_origin(field_type) is dict
or get_origin(field_type) is list
@@ -103,20 +102,6 @@ def load_from_env(
or field_type is list
):
cast_value = literal_eval(value)
# If it's a list of Pydantic models
if get_origin(field_type) is list:
inner_type = get_args(field_type)[
0
] # e.g., MCPSHTTPServerConfig
if isinstance(inner_type, type) and issubclass(
inner_type, BaseModel
):
cast_value = [
inner_type(**item)
if isinstance(item, dict)
else item
for item in cast_value
]
else:
if field_type is not None:
cast_value = field_type(value)

View File

@@ -1,6 +1,5 @@
import base64
import os
import re
from typing import Any
import httpx
@@ -11,7 +10,6 @@ from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
InstallationsService,
OwnerType,
ProviderType,
Repository,
@@ -25,7 +23,7 @@ from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class BitBucketService(BaseGitService, GitService, InstallationsService):
class BitBucketService(BaseGitService, GitService):
"""Default implementation of GitService for Bitbucket integration.
This is an extension point in OpenHands that allows applications to customize Bitbucket
@@ -188,106 +186,16 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
email=None, # Bitbucket API doesn't return email in this endpoint
)
def _parse_repository(
self, repo: dict, link_header: str | None = None
) -> Repository:
"""
Parse a Bitbucket API repository response into a Repository object.
Args:
repo: Repository data from Bitbucket API
link_header: Optional link header for pagination
Returns:
Repository object
"""
repo_id = repo.get('uuid', '')
workspace_slug = repo.get('workspace', {}).get('slug', '')
repo_slug = repo.get('slug', '')
full_name = (
f'{workspace_slug}/{repo_slug}' if workspace_slug and repo_slug else ''
)
is_public = not repo.get('is_private', True)
owner_type = OwnerType.ORGANIZATION
main_branch = repo.get('mainbranch', {}).get('name')
return Repository(
id=repo_id,
full_name=full_name, # type: ignore[arg-type]
git_provider=ProviderType.BITBUCKET,
is_public=is_public,
stargazers_count=None, # Bitbucket doesn't have stars
pushed_at=repo.get('updated_on'),
owner_type=owner_type,
link_header=link_header,
main_branch=main_branch,
)
async def search_repositories(
self, query: str, per_page: int, sort: str, order: str, public: bool
self,
query: str,
per_page: int,
sort: str,
order: str,
) -> list[Repository]:
"""Search for repositories."""
repositories = []
if public:
# Extract workspace and repo from URL
# URL format: https://{domain}/{workspace}/{repo}/{additional_params}
# Split by '/' and find workspace and repo parts
url_parts = query.split('/')
if len(url_parts) >= 5: # https:, '', domain, workspace, repo
workspace_slug = url_parts[3]
repo_name = url_parts[4]
repo = await self.get_repository_details_from_repo_name(
f'{workspace_slug}/{repo_name}'
)
repositories.append(repo)
return repositories
# Search for repos once workspace prefix exists
if '/' in query:
workspace_slug, repo_query = query.split('/', 1)
return await self.get_paginated_repos(
1, per_page, sort, workspace_slug, repo_query
)
all_installations = await self.get_installations()
# Workspace prefix isn't complete. Search workspace names and repos underneath each workspace
matching_workspace_slugs = [
installation for installation in all_installations if query in installation
]
for workspace_slug in matching_workspace_slugs:
# Get repositories where query matches workspace name
try:
repos = await self.get_paginated_repos(
1, per_page, sort, workspace_slug
)
repositories.extend(repos)
except Exception:
continue
for workspace_slug in all_installations:
# Get repositories in all workspaces where query matches repo name
try:
repos = await self.get_paginated_repos(
1, per_page, sort, workspace_slug, query
)
repositories.extend(repos)
except Exception:
continue
return repositories
async def _get_user_workspaces(self) -> list[dict[str, Any]]:
"""Get all workspaces the user has access to"""
url = f'{self.BASE_URL}/workspaces'
data, _ = await self._make_request(url)
return data.get('values', [])
# Bitbucket doesn't have a dedicated search endpoint like GitHub
return []
async def _fetch_paginated_data(
self, url: str, params: dict, max_items: int
@@ -324,107 +232,7 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
return all_items[:max_items] # Trim to max_items if needed
async def get_installations(
self, query: str | None = None, limit: int = 100
) -> list[str]:
workspaces_url = f'{self.BASE_URL}/workspaces'
params = {}
if query:
params['q'] = f'name~"{query}"'
workspaces = await self._fetch_paginated_data(workspaces_url, params, limit)
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,
query: str | None = 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,
}
if query:
params['q'] = f'name~"{query}"'
response, headers = await self._make_request(workspace_repos_url, params)
# Extract repositories from the response
repos = response.get('values', [])
# Extract next URL from response
next_link = response.get('next', '')
# Format the link header in a way that the frontend can understand
# The frontend expects a format like: <url>; rel="next"
# where the URL contains a page parameter
formatted_link_header = ''
if next_link:
# Extract the page number from the next URL if possible
page_match = re.search(r'[?&]page=(\d+)', next_link)
if page_match:
next_page = page_match.group(1)
# Format it in a way that extractNextPageFromLink in frontend can parse
formatted_link_header = (
f'<{workspace_repos_url}?page={next_page}>; rel="next"'
)
else:
# If we can't extract the page, just use the next URL as is
formatted_link_header = f'<{next_link}>; rel="next"'
repositories = [
self._parse_repository(repo, link_header=formatted_link_header)
for repo in repos
]
return repositories
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
async def get_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
@@ -477,7 +285,22 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
)
for repo in workspace_repos:
repositories.append(self._parse_repository(repo))
uuid = repo.get('uuid', '')
repositories.append(
Repository(
id=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
),
)
)
# Stop if we've reached the maximum number of repositories
if len(repositories) >= MAX_REPOS:
@@ -509,7 +332,23 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
url = f'{self.BASE_URL}/repositories/{owner}/{repo}'
data, _ = await self._make_request(url)
return self._parse_repository(data)
uuid = data.get('uuid', '')
main_branch = data.get('mainbranch', {}).get('name')
return Repository(
id=uuid,
full_name=f'{data.get("workspace", {}).get("slug", "")}/{data.get("slug", "")}',
git_provider=ProviderType.BITBUCKET,
is_public=data.get('is_private', True) is False,
stargazers_count=None, # Bitbucket doesn't have stars
pushed_at=data.get('updated_on'),
owner_type=(
OwnerType.ORGANIZATION
if data.get('workspace', {}).get('is_private') is False
else OwnerType.USER
),
main_branch=main_branch,
)
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository."""

View File

@@ -16,7 +16,6 @@ from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
InstallationsService,
OwnerType,
ProviderType,
Repository,
@@ -31,7 +30,7 @@ from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class GitHubService(BaseGitService, GitService, InstallationsService):
class GitHubService(BaseGitService, GitService):
"""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?
@@ -225,66 +224,14 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
ts = repo.get('pushed_at')
return datetime.strptime(ts, '%Y-%m-%dT%H:%M:%SZ') if ts else datetime.min
def _parse_repository(
self, repo: dict, link_header: str | None = None
) -> Repository:
"""
Parse a GitHub API repository response into a Repository object.
Args:
repo: Repository data from GitHub API
link_header: Optional link header for pagination
Returns:
Repository object
"""
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=link_header,
)
async def get_paginated_repos(
self,
page: int,
per_page: int,
sort: str,
installation_id: str | None,
query: str | None = 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 [
self._parse_repository(repo, link_header=next_link) for repo in response
]
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
async def get_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_installations()
installation_ids = await self.get_installation_ids()
# Iterate through each installation ID
for installation_id in installation_ids:
@@ -315,47 +262,59 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
all_repos = await self._fetch_paginated_repos(url, params, MAX_REPOS)
# Convert to Repository objects
return [self._parse_repository(repo) for repo in all_repos]
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
),
)
for repo in all_repos
]
async def get_installations(self) -> list[str]:
async def get_installation_ids(self) -> list[int]:
url = f'{self.BASE_URL}/user/installations'
response, _ = await self._make_request(url)
installations = response.get('installations', [])
return [str(i['id']) for i in installations]
return [i['id'] for i in installations]
async def search_repositories(
self, query: str, per_page: int, sort: str, order: str, public: bool
self, query: str, per_page: int, sort: str, order: str
) -> list[Repository]:
url = f'{self.BASE_URL}/search/repositories'
# Add is:public to the query to ensure we only search for public repositories
query_with_visibility = f'{query} is:public'
params = {
'q': query_with_visibility,
'per_page': per_page,
'sort': sort,
'order': order,
}
if public:
url_parts = query.split('/')
if len(url_parts) < 4:
return []
org = url_parts[3]
repo_name = url_parts[4]
# Add is:public to the query to ensure we only search for public repositories
params['q'] = f'in:name {org}/{repo_name} is:public'
# Perhaps we should go through all orgs and the search for repos under every org
# Currently it will only search user repos, and org repos when '/' is in the name
if not public and '/' in query:
org, repo_query = query.split('/', 1)
query_with_user = f'org:{org} in:name {repo_query}'
params['q'] = query_with_user
elif not public:
user = await self.get_user()
params['q'] = f'in:name {query} user:{user.login}'
response, _ = await self._make_request(url, params)
repo_items = response.get('items', [])
repos = [self._parse_repository(repo) for repo in repo_items]
repos = [
Repository(
id=str(repo.get('id')),
full_name=repo.get('full_name'),
stargazers_count=repo.get('stargazers_count'),
git_provider=ProviderType.GITHUB,
is_public=True,
owner_type=(
OwnerType.ORGANIZATION
if repo.get('owner', {}).get('type') == 'Organization'
else OwnerType.USER
),
)
for repo in repo_items
]
return repos
@@ -492,7 +451,18 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
url = f'{self.BASE_URL}/repos/{repository}'
repo, _ = await self._make_request(url)
return self._parse_repository(repo)
return Repository(
id=str(repo.get('id')),
full_name=repo.get('full_name'),
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
),
)
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository"""

View File

@@ -241,125 +241,38 @@ class GitLabService(BaseGitService, GitService):
company=response.get('organization'),
)
def _parse_repository(
self, repo: dict, link_header: str | None = None
) -> Repository:
"""
Parse a GitLab API project response into a Repository object.
Args:
repo: Project data from GitLab API
link_header: Optional link header for pagination
Returns:
Repository object
"""
return 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=link_header,
)
def _parse_gitlab_url(self, url: str) -> str | None:
"""
Parse a GitLab URL to extract the repository path.
Expected format: https://{domain}/{group}/{possibly_subgroup}/{repo}
Returns the full path from group onwards (e.g., 'group/subgroup/repo' or 'group/repo')
"""
try:
# Remove protocol and domain
if '://' in url:
url = url.split('://', 1)[1]
if '/' in url:
path = url.split('/', 1)[1]
else:
return None
# Clean up the path
path = path.strip('/')
if not path:
return None
# Split the path and remove empty parts
path_parts = [part for part in path.split('/') if part]
# We need at least 2 parts: group/repo
if len(path_parts) < 2:
return None
# Join all parts to form the full repository path
return '/'.join(path_parts)
except Exception:
return None
async def search_repositories(
self,
query: str,
per_page: int = 30,
sort: str = 'updated',
order: str = 'desc',
public: bool = False,
) -> list[Repository]:
if public:
# When public=True, query is a GitLab URL that we need to parse
repo_path = self._parse_gitlab_url(query)
if not repo_path:
return [] # Invalid URL format
repository = await self.get_repository_details_from_repo_name(repo_path)
return [repository]
return await self.get_paginated_repos(1, per_page, sort, None, query)
async def get_paginated_repos(
self,
page: int,
per_page: int,
sort: str,
installation_id: str | None,
query: str | None = None,
self, query: str, per_page: int = 30, sort: str = 'updated', order: str = 'desc'
) -> 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)
'membership': True, # Include projects user is a member of
'search': query,
'per_page': per_page,
'order_by': 'last_activity_at',
'sort': order,
'visibility': 'public',
}
if query:
params['search'] = query
params['search_namespaces'] = True
response, headers = await self._make_request(url, params)
next_link: str = headers.get('Link', '')
response, _ = await self._make_request(url, params)
repos = [
self._parse_repository(repo, link_header=next_link) for repo in response
Repository(
id=str(repo.get('id')),
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=True,
owner_type=(
OwnerType.ORGANIZATION
if repo.get('namespace', {}).get('kind') == 'group'
else OwnerType.USER
),
)
for repo in response
]
return repos
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitLab API
all_repos: list[dict] = []
@@ -397,7 +310,21 @@ class GitLabService(BaseGitService, GitService):
# Trim to MAX_REPOS if needed and convert to Repository objects
all_repos = all_repos[:MAX_REPOS]
return [self._parse_repository(repo) for repo in all_repos]
return [
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
),
)
for repo in all_repos
]
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories.
@@ -539,7 +466,18 @@ class GitLabService(BaseGitService, GitService):
url = f'{self.BASE_URL}/projects/{encoded_name}'
repo, _ = await self._make_request(url)
return self._parse_repository(repo)
return Repository(
id=str(repo.get('id')),
full_name=repo.get('path_with_namespace'),
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
),
)
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository"""

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from types import MappingProxyType
from typing import Annotated, Any, Coroutine, Literal, cast, overload
from typing import Annotated, Any, Coroutine, Literal, overload
from pydantic import (
BaseModel,
@@ -22,7 +22,6 @@ from openhands.integrations.service_types import (
AuthenticationError,
Branch,
GitService,
InstallationsService,
MicroagentParseError,
ProviderType,
Repository,
@@ -164,61 +163,16 @@ class ProviderHandler:
service = self._get_service(provider)
return await service.get_latest_token()
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]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> 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_all_repositories(sort, app_mode)
service_repos = await service.get_repositories(sort, app_mode)
all_repos.extend(service_repos)
except Exception as e:
logger.warning(f'Error fetching repos from {provider}: {e}')
@@ -242,34 +196,17 @@ class ProviderHandler:
async def search_repositories(
self,
selected_provider: ProviderType | None,
query: str,
per_page: int,
sort: str,
order: str,
) -> list[Repository]:
if selected_provider:
service = self._get_service(selected_provider)
public = self._is_repository_url(query, selected_provider)
try:
user_repos = await service.search_repositories(
query, per_page, sort, order, public
)
return self._deduplicate_repositories(user_repos)
except Exception as e:
logger.warning(
f'Error searching repos from select provider {selected_provider}: {e}'
)
return []
all_repos: list[Repository] = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
public = self._is_repository_url(query, provider)
service_repos = await service.search_repositories(
query, per_page, sort, order, public
query, per_page, sort, order
)
all_repos.extend(service_repos)
except Exception as e:
@@ -278,26 +215,6 @@ class ProviderHandler:
return all_repos
def _is_repository_url(self, query: str, provider: ProviderType) -> bool:
"""Check if the query is a repository URL."""
custom_host = self.provider_tokens[provider].host
custom_host_exists = custom_host and custom_host in query
default_host_exists = self.PROVIDER_DOMAINS[provider] in query
return query.startswith(('http://', 'https://')) and (
custom_host_exists or default_host_exists
)
def _deduplicate_repositories(self, repos: list[Repository]) -> list[Repository]:
"""Remove duplicate repositories based on full_name."""
seen = set()
unique_repos = []
for repo in repos:
if repo.full_name not in seen:
seen.add(repo.id)
unique_repos.append(repo)
return unique_repos
async def set_event_stream_secrets(
self,
event_stream: EventStream,

View File

@@ -434,12 +434,6 @@ class BaseGitService(ABC):
return microagents
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"""
@@ -464,26 +458,17 @@ class GitService(Protocol):
...
async def search_repositories(
self, query: str, per_page: int, sort: str, order: str, public: bool
) -> list[Repository]:
"""Search for public repositories"""
...
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,
query: str,
per_page: int,
sort: str,
installation_id: str | None,
query: str | None = None,
order: str,
) -> list[Repository]:
"""Get a page of repositories for the authenticated user"""
"""Search for repositories"""
...
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
"""Get repositories for the authenticated user"""
...
async def get_suggested_tasks(self) -> list[SuggestedTask]:

View File

@@ -18,7 +18,6 @@ from litellm import ModelInfo, PromptTokensDetails
from litellm import completion as litellm_completion
from litellm import completion_cost as litellm_completion_cost
from litellm.exceptions import (
APIConnectionError,
RateLimitError,
ServiceUnavailableError,
)
@@ -41,7 +40,6 @@ __all__ = ['LLM']
# tuple of exceptions to retry on
LLM_RETRY_EXCEPTIONS: tuple[type[Exception], ...] = (
APIConnectionError,
RateLimitError,
ServiceUnavailableError,
litellm.Timeout,

View File

@@ -104,17 +104,6 @@ async def create_mcp_clients(
client = MCPClient()
try:
await client.connect_stdio(server)
# Log which tools this specific server provides
tool_names = [tool.name for tool in client.tools]
server_name = getattr(
server, 'name', f'{server.command} {" ".join(server.args or [])}'
)
logger.debug(
f'Successfully connected to MCP stdio server {server_name} - '
f'provides {len(tool_names)} tools: {tool_names}'
)
mcp_clients.append(client)
except Exception as e:
# Error is already logged and collected in client.connect_stdio()
@@ -122,7 +111,6 @@ async def create_mcp_clients(
continue
is_shttp = isinstance(server, MCPSHTTPServerConfig)
connection_type = 'SHTTP' if is_shttp else 'SSE'
logger.info(
f'Initializing MCP agent for {server} with {connection_type} connection...'
@@ -132,13 +120,6 @@ async def create_mcp_clients(
try:
await client.connect_http(server, conversation_id=conversation_id)
# Log which tools this specific server provides
tool_names = [tool.name for tool in client.tools]
logger.debug(
f'Successfully connected to MCP STTP server {server.url} - '
f'provides {len(tool_names)} tools: {tool_names}'
)
# Only add the client to the list after a successful connection
mcp_clients.append(client)
@@ -174,7 +155,6 @@ async def fetch_mcp_tools_from_config(
mcp_tools = []
try:
logger.debug(f'Creating MCP clients with config: {mcp_config}')
# Create clients - this will fetch tools but not maintain active connections
mcp_clients = await create_mcp_clients(
mcp_config.sse_servers,
@@ -313,8 +293,9 @@ async def add_mcp_tools_to_agent(
updated_mcp_config, use_stdio=isinstance(runtime, CLIRuntime)
)
tool_names = [tool['function']['name'] for tool in mcp_tools]
logger.info(f'Loaded {len(mcp_tools)} MCP tools: {tool_names}')
logger.info(
f'Loaded {len(mcp_tools)} MCP tools: {[tool["function"]["name"] for tool in mcp_tools]}'
)
# Set the MCP tools on the agent
agent.set_mcp_tools(mcp_tools)

View File

@@ -363,21 +363,20 @@ class ActionExecutor:
'$env:GIT_CONFIG = (Join-Path (Get-Location) ".git_config")'
)
else:
INIT_COMMANDS.append(
f'git config --file ./.git_config user.name "{self.git_user_name}"'
# Linux/macOS, local
base_git_config = (
f'git config --file ./.git_config user.name "{self.git_user_name}" && '
f'git config --file ./.git_config user.email "{self.git_user_email}" && '
'export GIT_CONFIG=$(pwd)/.git_config'
)
INIT_COMMANDS.append(
f'git config --file ./.git_config user.email "{self.git_user_email}"'
)
INIT_COMMANDS.append('export GIT_CONFIG=$(pwd)/.git_config')
INIT_COMMANDS.append(base_git_config)
else:
# Non-local (implies Linux/macOS)
INIT_COMMANDS.append(
f'git config --global user.name "{self.git_user_name}"'
)
INIT_COMMANDS.append(
base_git_config = (
f'git config --global user.name "{self.git_user_name}" && '
f'git config --global user.email "{self.git_user_email}"'
)
INIT_COMMANDS.append(base_git_config)
# Determine no-pager command
if is_windows:
@@ -676,9 +675,7 @@ class ActionExecutor:
if __name__ == '__main__':
logger.warning('Starting Action Execution Server')
logger.warning('Arguments passed to script:')
for i, arg in enumerate(sys.argv):
logger.warning(f'Argument {i}: {arg}')
parser = argparse.ArgumentParser()
parser.add_argument('port', type=int, help='Port to listen on')
parser.add_argument('--working-dir', type=str, help='Working directory')

View File

@@ -580,7 +580,7 @@ fi
if not files:
self.log(
'debug',
'warning',
f'No files found in {source_description} microagents directory: {microagents_dir}',
)
return loaded_microagents
@@ -735,14 +735,27 @@ fi
'Executing clone command for org-level repo',
)
action = CmdRunAction(command=clone_cmd)
# Make the organization microagents clone operation blocking with a 60-second timeout
self.log(
'info',
f'Cloning org-level microagents from {org_openhands_repo} (blocking with 60s timeout)',
)
action = CmdRunAction(command=clone_cmd, blocking=True)
action.set_hard_timeout(60) # 60 seconds timeout
obs = self.run_action(action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code == 0:
self.log(
'info',
f'Successfully cloned org-level microagents from {org_openhands_repo}',
)
if isinstance(obs, CmdOutputObservation):
if obs.exit_code == 0:
self.log(
'info',
f'Successfully cloned org-level microagents from {org_openhands_repo}',
)
else:
self.log(
'warning',
f'Failed to clone org-level microagents from {org_openhands_repo}: {obs.content}',
)
return loaded_microagents
# Load microagents from the org-level repo
org_microagents_dir = org_repo_dir / 'microagents'

View File

@@ -49,124 +49,72 @@ def init_user_and_working_directory(
if username == os.getenv('USER') and username not in ['root', 'openhands']:
return None
# Skip root since it is already created
if username != 'root':
# Check if the username already exists
logger.info(f'Attempting to create user `{username}` with UID {user_id}.')
existing_user_id = -1
try:
result = subprocess.run(
f'id -u {username}', shell=True, check=True, capture_output=True
)
existing_user_id = int(result.stdout.decode().strip())
# The user ID already exists, skip setup
if existing_user_id == user_id:
logger.debug(
f'User `{username}` already has the provided UID {user_id}. Skipping user setup.'
)
else:
logger.warning(
f'User `{username}` already exists with UID {existing_user_id}. Skipping user setup.'
)
return existing_user_id
return None
except subprocess.CalledProcessError as e:
# Returncode 1 indicates, that the user does not exist yet
if e.returncode == 1:
logger.info(
f'User `{username}` does not exist. Proceeding with user creation.'
)
else:
logger.error(
f'Error checking user `{username}`, skipping setup:\n{e}\n'
)
raise
# Add sudoer
sudoer_line = r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"
output = subprocess.run(sudoer_line, shell=True, capture_output=True)
if output.returncode != 0:
raise RuntimeError(f'Failed to add sudoer: {output.stderr.decode()}')
logger.debug(f'Added sudoer successfully. Output: [{output.stdout.decode()}]')
command = (
f'useradd -rm -d /home/{username} -s /bin/bash '
f'-g root -G sudo -u {user_id} {username}'
)
output = subprocess.run(command, shell=True, capture_output=True)
if output.returncode == 0:
logger.debug(
f'Added user `{username}` successfully with UID {user_id}. Output: [{output.stdout.decode()}]'
)
else:
raise RuntimeError(
f'Failed to create user `{username}` with UID {user_id}. Output: [{output.stderr.decode()}]'
)
# First create the working directory, independent of the user
logger.debug(f'Client working directory: {initial_cwd}')
command = f'umask 002; mkdir -p {initial_cwd}'
output = subprocess.run(command, shell=True, capture_output=True)
out_str = output.stdout.decode()
logger.debug(f'mkdir command result: returncode={output.returncode}, stdout=[{out_str}], stderr=[{output.stderr.decode()}]')
# Check current ownership before changing it
check_cmd = f'ls -la {initial_cwd}'
check_output = subprocess.run(check_cmd, shell=True, capture_output=True)
logger.debug(f'Current ownership: {check_output.stdout.decode()}')
# Check if we're running as root
whoami_output = subprocess.run('whoami', shell=True, capture_output=True)
current_user = whoami_output.stdout.decode().strip()
logger.debug(f'Current user: {current_user}')
# Use sudo only if not running as root
sudo_prefix = '' if current_user == 'root' else 'sudo '
command = f'{sudo_prefix}chown -R {username}:{username} {initial_cwd}'
logger.debug(f'Executing chown command: {command}')
command = f'chown -R {username}:root {initial_cwd}'
output = subprocess.run(command, shell=True, capture_output=True)
out_str += output.stdout.decode()
logger.debug(f'chown command result: returncode={output.returncode}, stdout=[{output.stdout.decode()}], stderr=[{output.stderr.decode()}]')
if output.returncode != 0 or output.stderr:
err_str = output.stderr.decode()
logger.error(f'chown command failed: returncode={output.returncode}, stderr: {err_str}')
out_str += f' [stderr: {err_str}]'
command = f'{sudo_prefix}chmod g+rw {initial_cwd}'
logger.debug(f'Executing chmod command: {command}')
command = f'chmod g+rw {initial_cwd}'
output = subprocess.run(command, shell=True, capture_output=True)
out_str += output.stdout.decode()
logger.debug(f'chmod command result: returncode={output.returncode}, stdout=[{output.stdout.decode()}], stderr=[{output.stderr.decode()}]')
if output.returncode != 0 or output.stderr:
err_str = output.stderr.decode()
logger.error(f'chmod command failed: returncode={output.returncode}, stderr: {err_str}')
out_str += f' [stderr: {err_str}]'
# Verify final ownership
check_cmd = f'ls -la {initial_cwd}'
check_output = subprocess.run(check_cmd, shell=True, capture_output=True)
final_ownership = check_output.stdout.decode()
logger.debug(f'Final ownership: {final_ownership}')
# If chown failed and directory is still owned by root, try alternative approaches
if 'root root' in final_ownership and username != 'root':
logger.warning(f'Directory {initial_cwd} is still owned by root, trying alternative approaches')
# Try to make it writable for the user's group
alt_command = f'{sudo_prefix}chmod -R g+rwx {initial_cwd}'
logger.debug(f'Executing alternative chmod command: {alt_command}')
alt_output = subprocess.run(alt_command, shell=True, capture_output=True)
logger.debug(f'Alternative chmod result: returncode={alt_output.returncode}, stderr=[{alt_output.stderr.decode()}]')
# Try to add the user to the root group (as a last resort)
if alt_output.returncode != 0:
group_command = f'{sudo_prefix}usermod -aG root {username}'
logger.debug(f'Executing usermod command: {group_command}')
group_output = subprocess.run(group_command, shell=True, capture_output=True)
logger.debug(f'Usermod result: returncode={group_output.returncode}, stderr=[{group_output.stderr.decode()}]')
logger.debug(f'Created working directory. Output: [{out_str}]')
# Skip root since it is already created
if username == 'root':
return None
# Check if the username already exists
existing_user_id = -1
try:
result = subprocess.run(
f'id -u {username}', shell=True, check=True, capture_output=True
)
existing_user_id = int(result.stdout.decode().strip())
# The user ID already exists, skip setup
if existing_user_id == user_id:
logger.debug(
f'User `{username}` already has the provided UID {user_id}. Skipping user setup.'
)
else:
logger.warning(
f'User `{username}` already exists with UID {existing_user_id}. Skipping user setup.'
)
return existing_user_id
return None
except subprocess.CalledProcessError as e:
# Returncode 1 indicates, that the user does not exist yet
if e.returncode == 1:
logger.debug(
f'User `{username}` does not exist. Proceeding with user creation.'
)
else:
logger.error(f'Error checking user `{username}`, skipping setup:\n{e}\n')
raise
# Add sudoer
sudoer_line = r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"
output = subprocess.run(sudoer_line, shell=True, capture_output=True)
if output.returncode != 0:
raise RuntimeError(f'Failed to add sudoer: {output.stderr.decode()}')
logger.debug(f'Added sudoer successfully. Output: [{output.stdout.decode()}]')
command = (
f'useradd -rm -d /home/{username} -s /bin/bash '
f'-g root -G sudo -u {user_id} {username}'
)
output = subprocess.run(command, shell=True, capture_output=True)
if output.returncode == 0:
logger.debug(
f'Added user `{username}` successfully with UID {user_id}. Output: [{output.stdout.decode()}]'
)
else:
raise RuntimeError(
f'Failed to create user `{username}` with UID {user_id}. Output: [{output.stderr.decode()}]'
)
return None

View File

@@ -56,18 +56,24 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="/openhands/
# Add /openhands/bin to PATH
ENV PATH="/openhands/bin:${PATH}"
# Remove UID 1000 named pn or ubuntu, so the 'openhands' user can be created from ubuntu hosts
RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi)
# Create necessary directories
RUN mkdir -p /openhands && \
mkdir -p /openhands/logs && \
mkdir -p /openhands/poetry
# ================================================================
# Define Docker installation macro
{% macro install_docker() %}
# Install Docker following official documentation
# https://docs.docker.com/engine/install/ubuntu/
# https://docs.docker.com/engine/install/debian/
RUN \
# Determine OS type and install accordingly
if [[ "{{ base_image }}" == *"ubuntu"* ]]; then \
if [[ "{{ base_image }}" == *"ubuntu"* ]] || [[ "{{ base_image }}" == *"mswebench"* ]]; then \
# Handle Ubuntu (following https://docs.docker.com/engine/install/ubuntu/)
# Add Docker's official GPG key
apt-get update && \
@@ -103,35 +109,6 @@ RUN \
# Configure Docker daemon with MTU 1450 to prevent packet fragmentation issues
RUN mkdir -p /etc/docker && \
echo '{"mtu": 1450}' > /etc/docker/daemon.json
# Remove UID 1000 and GID 1000 users/groups that might conflict with openhands user
RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi) && \
(if getent group 1000 | grep -q pn; then groupdel pn; fi) && \
(if getent group 1000 | grep -q ubuntu; then groupdel ubuntu; fi)
# Create openhands group and user
RUN groupadd -g 1000 openhands && \
useradd -u 1000 -g 1000 -m -s /bin/bash openhands && \
usermod -aG sudo openhands && \
echo 'openhands ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# Create necessary directories
RUN mkdir -p /openhands && \
mkdir -p /openhands/logs && \
mkdir -p /openhands/poetry && \
mkdir -p /workspace && \
mkdir -p /workspace/.openhands && \
mkdir -p /home/openhands/.openhands && \
chown -R openhands:openhands /openhands && \
chown -R openhands:openhands /workspace && \
chown -R openhands:openhands /home/openhands
{% endmacro %}
# Install Docker only if not a swebench or mswebench image
{% if not ('swebench' in base_image) and not ('mswebench' in base_image) %}
{{ install_docker() }}
{% endif %}
# ================================================================
{% endmacro %}
@@ -165,8 +142,7 @@ RUN if [ -z "${RELEASE_TAG}" ]; then \
if [ -d "${OPENVSCODE_SERVER_ROOT}" ]; then rm -rf "${OPENVSCODE_SERVER_ROOT}"; fi && \
mv ${RELEASE_TAG}-linux-${arch} ${OPENVSCODE_SERVER_ROOT} && \
cp ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/openvscode-server ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/code && \
rm -f ${RELEASE_TAG}-linux-${arch}.tar.gz && \
chown -R openhands:openhands ${OPENVSCODE_SERVER_ROOT}
rm -f ${RELEASE_TAG}-linux-${arch}.tar.gz
@@ -175,12 +151,10 @@ RUN if [ -z "${RELEASE_TAG}" ]; then \
{% macro install_vscode_extensions() %}
# Install our custom extension
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world && \
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/hello-world/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world/ && \
chown -R openhands:openhands ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/hello-world/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world/
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor && \
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/memory-monitor/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor/ && \
chown -R openhands:openhands ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/memory-monitor/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor/
# Some extension dirs are removed because they trigger false positives in vulnerability scans.
RUN rm -rf ${OPENVSCODE_SERVER_ROOT}/extensions/{handlebars,pug,json,diff,grunt,ini,npm}
@@ -203,12 +177,9 @@ RUN \
{% 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 and ownership
# Set permissions
chmod -R g+rws /openhands/poetry && \
chown -R openhands:openhands /openhands/poetry && \
mkdir -p /openhands/workspace && chmod -R g+rws,o+rw /openhands/workspace && \
chown -R openhands:openhands /openhands/workspace && \
chown -R openhands:openhands /openhands/micromamba && \
# Clean up
/openhands/micromamba/bin/micromamba run -n openhands poetry cache clear --all . -n && \
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
@@ -229,8 +200,7 @@ RUN \
RUN mkdir -p /openhands/micromamba/bin && \
/bin/bash -c "PREFIX_LOCATION=/openhands/micromamba BIN_FOLDER=/openhands/micromamba/bin INIT_YES=no CONDA_FORGE_YES=yes $(curl -L https://micro.mamba.pm/install.sh)" && \
/openhands/micromamba/bin/micromamba config remove channels defaults && \
/openhands/micromamba/bin/micromamba config list && \
chown -R openhands:openhands /openhands/micromamba
/openhands/micromamba/bin/micromamba config list
# Create the openhands virtual environment and install poetry and python
RUN /openhands/micromamba/bin/micromamba create -n openhands -y && \
@@ -241,12 +211,11 @@ RUN \
if [ -d /openhands/code ]; then rm -rf /openhands/code; fi && \
mkdir -p /openhands/code/openhands && \
touch /openhands/code/openhands/__init__.py && \
chown -R openhands:openhands /openhands/code && \
# Set global git configuration to ensure proper author/committer information
git config --global user.name "openhands" && \
git config --global user.email "openhands@all-hands.dev"
COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openhands/code/
COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
{{ install_dependencies() }}
@@ -257,43 +226,14 @@ COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openh
{{ setup_vscode_server() }}
# ================================================================
# Ensure openhands user and directories exist (for non-scratch builds)
# ================================================================
{% if not build_from_scratch %}
# Remove UID 1000 and GID 1000 users/groups that might conflict with openhands user
RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi) && \
(if getent group 1000 | grep -q pn; then groupdel pn; fi) && \
(if getent group 1000 | grep -q ubuntu; then groupdel ubuntu; fi)
# Create openhands group and user if they don't exist
RUN (getent group openhands || groupadd -g 1000 openhands) && \
(getent passwd openhands || useradd -u 1000 -g 1000 -m -s /bin/bash openhands) && \
usermod -aG sudo openhands && \
echo 'openhands ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# Create necessary directories and set ownership
RUN mkdir -p /openhands && \
mkdir -p /openhands/logs && \
mkdir -p /openhands/poetry && \
mkdir -p /workspace && \
mkdir -p /workspace/.openhands && \
mkdir -p /home/openhands/.openhands && \
chown -R openhands:openhands /openhands && \
chown -R openhands:openhands /workspace && \
chown -R openhands:openhands /home/openhands
{% endif %}
# ================================================================
# Copy Project source files
# ================================================================
RUN if [ -d /openhands/code/openhands ]; then rm -rf /openhands/code/openhands; fi
COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openhands/code/
COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
COPY --chown=openhands:openhands ./code/openhands /openhands/code/openhands
RUN chmod a+rwx /openhands/code/openhands/__init__.py && \
chown -R openhands:openhands /openhands/code
COPY ./code/openhands /openhands/code/openhands
RUN chmod a+rwx /openhands/code/openhands/__init__.py
@@ -307,12 +247,3 @@ RUN chmod a+rwx /openhands/code/openhands/__init__.py && \
# Install extra dependencies if specified
{% if extra_deps %}RUN {{ extra_deps }} {% endif %}
# Copy entrypoint script and make it executable
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Set the entrypoint to run as root first, then switch to openhands
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
# Note: We don't set USER openhands here because the entrypoint handles the user switch

View File

@@ -1,32 +0,0 @@
#!/bin/bash
set -e
# This entrypoint script runs as root to fix workspace ownership before switching to openhands user
echo "🔧 OpenHands Runtime Entrypoint - Fixing workspace ownership..."
# Check if /workspace exists and fix ownership
if [ -d "/workspace" ]; then
echo "📁 Found /workspace directory, checking ownership..."
ls -la /workspace
# Fix ownership to openhands:openhands
echo "🔧 Changing ownership to openhands:openhands..."
chown -R openhands:openhands /workspace
chmod -R g+rw /workspace
echo "✅ Ownership fixed:"
ls -la /workspace
else
echo "⚠️ /workspace directory not found, will be created later"
fi
# If arguments are provided, execute them as the openhands user
if [ $# -gt 0 ]; then
echo "🚀 Switching to openhands user and executing: $@"
# Use exec to replace the current process and preserve all arguments
exec su openhands -c "exec \"\$@\"" -- "$@"
else
echo "🚀 Switching to openhands user with bash shell"
exec su - openhands
fi

View File

@@ -331,12 +331,10 @@ class StandaloneConversationManager(ConversationManager):
)
await self.close_session(oldest_conversation_id)
config = self.config.model_copy(deep=True)
session = Session(
sid=sid,
file_store=self.file_store,
config=config,
config=self.config,
sio=self.sio,
user_id=user_id,
)

View File

@@ -13,7 +13,6 @@ from openhands.integrations.provider import (
from openhands.integrations.service_types import (
AuthenticationError,
Branch,
ProviderType,
Repository,
SuggestedTask,
UnknownException,
@@ -34,43 +33,9 @@ from openhands.server.user_auth import (
app = APIRouter(prefix='/api/user', dependencies=get_dependencies())
@app.get('/installations', response_model=list[str])
async def get_user_installations(
provider: ProviderType,
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,
)
if provider == ProviderType.GITHUB:
return await client.get_github_installations()
elif provider == ProviderType.BITBUCKET:
return await client.get_bitbucket_workspaces()
else:
return JSONResponse(
content=f"Provider {provider} doesn't support installations",
status_code=status.HTTP_400_BAD_REQUEST,
)
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),
@@ -83,14 +48,7 @@ async def get_user_repositories(
)
try:
return await client.get_repositories(
sort,
server_config.app_mode,
selected_provider,
page,
per_page,
installation_id,
)
return await client.get_repositories(sort, server_config.app_mode)
except AuthenticationError as e:
logger.info(
@@ -161,20 +119,17 @@ async def search_repositories(
per_page: int = 5,
sort: str = 'stars',
order: str = 'desc',
selected_provider: ProviderType | 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),
) -> list[Repository] | JSONResponse:
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
provider_tokens=provider_tokens, external_auth_token=access_token
)
try:
repos: list[Repository] = await client.search_repositories(
selected_provider, query, per_page, sort, order
query, per_page, sort, order
)
return repos
@@ -191,10 +146,10 @@ async def search_repositories(
)
logger.info(
f'Returning 401 Unauthorized - Git provider token required for user_id: {user_id}'
f'Returning 401 Unauthorized - GitHub token required for user_id: {user_id}'
)
return JSONResponse(
content='Git provider token required.',
content='GitHub token required.',
status_code=status.HTTP_401_UNAUTHORIZED,
)

View File

@@ -13,7 +13,7 @@ from openhands.core.config.condenser_config import (
ConversationWindowCondenserConfig,
LLMSummarizingCondenserConfig,
)
from openhands.core.config.mcp_config import OpenHandsMCPConfigImpl
from openhands.core.config.mcp_config import MCPConfig, OpenHandsMCPConfigImpl
from openhands.core.exceptions import MicroagentValidationError
from openhands.core.logger import OpenHandsLoggerAdapter
from openhands.core.schema import AgentState
@@ -122,12 +122,6 @@ class Session:
or settings.sandbox_runtime_container_image
else self.config.sandbox.runtime_container_image
)
# Set Git user configuration if provided in settings
if hasattr(settings, 'git_user_name') and settings.git_user_name:
self.config.git_user_name = settings.git_user_name
if hasattr(settings, 'git_user_email') and settings.git_user_email:
self.config.git_user_email = settings.git_user_email
max_iterations = settings.max_iterations or self.config.max_iterations
# Prioritize settings over config for max_budget_per_task
@@ -149,8 +143,8 @@ class Session:
self.config.sandbox.api_key = settings.sandbox_api_key.get_secret_value()
# NOTE: this need to happen AFTER the config is updated with the search_api_key
self.logger.debug(
f'MCP configuration before setup - self.config.mcp_config: {self.config.mcp}'
self.config.mcp = settings.mcp_config or MCPConfig(
sse_servers=[], stdio_servers=[]
)
# Add OpenHands' MCP server by default
openhands_mcp_server, openhands_mcp_stdio_servers = (
@@ -158,17 +152,10 @@ class Session:
self.config.mcp_host, self.config, self.user_id
)
)
if openhands_mcp_server:
self.config.mcp.shttp_servers.append(openhands_mcp_server)
self.logger.debug('Added default MCP HTTP server to config')
self.config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
self.logger.debug(
f'MCP configuration after setup - self.config.mcp: {self.config.mcp}'
)
# TODO: override other LLM config & agent config groups (#2075)
llm = self._create_llm(agent_cls)
@@ -276,7 +263,6 @@ class Session:
async def _on_event(self, event: Event) -> None:
"""Callback function for events that mainly come from the agent.
Event is the base class for any agent action and observation.
Args:

View File

@@ -45,8 +45,6 @@ class Settings(BaseModel):
max_budget_per_task: float | None = None
email: str | None = None
email_verified: bool | None = None
git_user_name: str | None = None
git_user_email: str | None = None
model_config = ConfigDict(
validate_assignment=True,

View File

@@ -603,7 +603,7 @@ async def test_reset_with_pending_action_no_observation(mock_agent, mock_event_s
assert isinstance(error_obs, ErrorObservation)
assert (
error_obs.content
== 'The action has not been executed due to a runtime error. The runtime system may have crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.'
== 'The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.'
)
assert error_obs.tool_call_metadata == pending_action.tool_call_metadata
assert error_obs._cause == pending_action.id
@@ -617,53 +617,6 @@ async def test_reset_with_pending_action_no_observation(mock_agent, mock_event_s
await controller.close()
@pytest.mark.asyncio
async def test_reset_with_pending_action_stopped_state(mock_agent, mock_event_stream):
"""Test reset() when there's a pending action and agent state is STOPPED."""
controller = AgentController(
agent=mock_agent,
event_stream=mock_event_stream,
iteration_delta=10,
sid='test',
confirmation_mode=False,
headless_mode=True,
)
mock_event_stream.add_event.assert_called_once() # add SystemMessageAction
mock_event_stream.add_event.reset_mock()
# Create a pending action with tool call metadata
pending_action = CmdRunAction(command='test')
pending_action.tool_call_metadata = {
'function': 'test_function',
'args': {'arg1': 'value1'},
}
controller._pending_action = pending_action
# Set agent state to STOPPED
controller.state.agent_state = AgentState.STOPPED
# Call reset
controller._reset()
# Verify that an ErrorObservation was added to the event stream
mock_event_stream.add_event.assert_called_once()
args, kwargs = mock_event_stream.add_event.call_args
error_obs, source = args
assert isinstance(error_obs, ErrorObservation)
assert error_obs.content == 'Stop button pressed. The action has not been executed.'
assert error_obs.tool_call_metadata == pending_action.tool_call_metadata
assert error_obs._cause == pending_action.id
assert source == EventSource.AGENT
# Verify that pending action was reset
assert controller._pending_action is None
# Verify that agent.reset() was called
mock_agent.reset.assert_called_once()
await controller.close()
@pytest.mark.asyncio
async def test_reset_with_pending_action_existing_observation(
mock_agent, mock_event_stream

View File

@@ -1,88 +0,0 @@
from unittest.mock import patch
import pytest
from litellm.exceptions import APIConnectionError
from openhands.core.config import LLMConfig
from openhands.llm.llm import LLM
@pytest.fixture
def default_config():
return LLMConfig(
model='gpt-4o',
api_key='test_key',
num_retries=2,
retry_min_wait=1,
retry_max_wait=2,
)
@patch('openhands.llm.llm.litellm_completion')
def test_completion_retries_api_connection_error(
mock_litellm_completion, default_config
):
"""Test that APIConnectionError is properly retried."""
# Mock the litellm_completion to first raise an APIConnectionError, then return a successful response
mock_litellm_completion.side_effect = [
APIConnectionError(
message='API connection error',
llm_provider='test_provider',
model='test_model',
),
{'choices': [{'message': {'content': 'Retry successful'}}]},
]
# Create an LLM instance and call completion
llm = LLM(config=default_config)
response = llm.completion(
messages=[{'role': 'user', 'content': 'Hello!'}],
stream=False,
)
# Verify that the retry was successful
assert response['choices'][0]['message']['content'] == 'Retry successful'
assert mock_litellm_completion.call_count == 2 # Initial call + 1 retry
@patch('openhands.llm.llm.litellm_completion')
def test_completion_max_retries_api_connection_error(
mock_litellm_completion, default_config
):
"""Test that APIConnectionError respects max retries."""
# Mock the litellm_completion to raise APIConnectionError multiple times
mock_litellm_completion.side_effect = [
APIConnectionError(
message='API connection error 1',
llm_provider='test_provider',
model='test_model',
),
APIConnectionError(
message='API connection error 2',
llm_provider='test_provider',
model='test_model',
),
APIConnectionError(
message='API connection error 3',
llm_provider='test_provider',
model='test_model',
),
]
# Create an LLM instance and call completion
llm = LLM(config=default_config)
# The completion should raise an APIConnectionError after exhausting all retries
with pytest.raises(APIConnectionError) as excinfo:
llm.completion(
messages=[{'role': 'user', 'content': 'Hello!'}],
stream=False,
)
# Verify that the correct number of retries were attempted
# The actual behavior is that it tries the initial call + num_retries (not +1)
assert mock_litellm_completion.call_count == default_config.num_retries
# The exception doesn't contain retry information in the current implementation
# Just verify that we got an APIConnectionError
assert 'API connection error' in str(excinfo.value)

View File

@@ -450,7 +450,7 @@ async def test_bitbucket_sort_parameter_mapping():
]
# Call get_repositories with sort='pushed'
await service.get_all_repositories('pushed', AppMode.SAAS)
await service.get_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_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify that all three requests were made (workspaces + 2 pages of repos)
assert mock_request.call_count == 3
@@ -619,14 +619,14 @@ 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_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
# Verify owner_type is correctly set for user repositories (private workspace)
for repo in repositories:
assert repo.owner_type == OwnerType.ORGANIZATION
assert repo.owner_type == OwnerType.USER
assert isinstance(repo, Repository)
assert repo.git_provider == ServiceProviderType.BITBUCKET
@@ -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_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_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_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got repositories from both workspaces
assert len(repositories) == 2
@@ -715,10 +715,44 @@ async def test_bitbucket_get_repositories_mixed_owner_types():
user_repo = next(repo for repo in repositories if 'user-repo' in repo.full_name)
org_repo = next(repo for repo in repositories if 'org-repo' in repo.full_name)
assert user_repo.owner_type == OwnerType.ORGANIZATION
assert user_repo.owner_type == OwnerType.USER
assert org_repo.owner_type == OwnerType.ORGANIZATION
@pytest.mark.asyncio
async def test_bitbucket_get_repositories_owner_type_fallback():
"""Test that owner_type defaults to USER when workspace is private."""
service = BitBucketService(token=SecretStr('test-token'))
# Mock repository data with private workspace (should default to USER)
mock_workspaces = [{'slug': 'test-user', 'name': 'Test User'}]
mock_repos = [
{
'uuid': 'repo-1',
'slug': 'user-repo',
'workspace': {'slug': 'test-user', 'is_private': True}, # Private workspace
'is_private': False,
'updated_on': '2023-01-01T00:00:00Z',
},
{
'uuid': 'repo-2',
'slug': 'another-user-repo',
'workspace': {'slug': 'test-user', 'is_private': True}, # Private workspace
'is_private': True,
'updated_on': '2023-01-02T00:00:00Z',
},
]
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)
# Verify all repositories default to USER owner_type for private workspaces
for repo in repositories:
assert repo.owner_type == OwnerType.USER
# Setup.py Bitbucket Token Tests
@patch('openhands.core.setup.call_async_from_sync')
@patch('openhands.core.setup.get_file_store')

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_installations', return_value=[123]),
patch.object(service, 'get_installation_ids', return_value=[123]),
):
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_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_installations', return_value=[123]),
patch.object(service, 'get_installation_ids', return_value=[123]),
):
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_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_installations', return_value=[123]),
patch.object(service, 'get_installation_ids', return_value=[123]),
):
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_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_installations', return_value=[123]),
patch.object(service, 'get_installation_ids', return_value=[123]),
):
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_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_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_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_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_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_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -162,281 +162,8 @@ 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_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify all repositories default to USER owner_type
for repo in repositories:
assert repo.owner_type == OwnerType.USER
@pytest.mark.asyncio
async def test_gitlab_search_repositories_uses_membership_and_min_access_level():
"""Test that search_repositories uses membership and min_access_level for non-public searches."""
service = GitLabService(token=SecretStr('test-token'))
# Mock repository data
mock_repos = [
{
'id': 123,
'path_with_namespace': 'test-user/search-repo1',
'star_count': 10,
'visibility': 'private',
'namespace': {'kind': 'user'},
},
{
'id': 456,
'path_with_namespace': 'test-org/search-repo2',
'star_count': 25,
'visibility': 'private',
'namespace': {'kind': 'group'},
},
]
with patch.object(service, '_make_request') as mock_request:
mock_request.return_value = (mock_repos, {})
# Test non-public search (should use membership and min_access_level)
repositories = await service.search_repositories(
query='test-query', per_page=30, sort='updated', order='desc', public=False
)
# Verify the request was made with correct parameters
mock_request.assert_called_once()
call_args = mock_request.call_args
url = call_args[0][0]
params = call_args[0][1] # params is the second positional argument
assert url == f'{service.BASE_URL}/projects'
assert params['search'] == 'test-query'
assert params['per_page'] == '30' # GitLab service converts to string
assert params['order_by'] == 'last_activity_at'
assert params['sort'] == 'desc'
assert params['membership'] is True
assert params['search_namespaces'] is True # Added by implementation
assert 'min_access_level' not in params # Not set by current implementation
assert 'owned' not in params
assert 'visibility' not in params
# Verify we got the expected repositories
assert len(repositories) == 2
@pytest.mark.asyncio
async def test_gitlab_search_repositories_public_search_legacy():
"""Test that search_repositories returns empty list for non-URL queries when public=True."""
service = GitLabService(token=SecretStr('test-token'))
with patch.object(service, '_make_request') as mock_request:
# Test public search with non-URL query (should return empty list)
repositories = await service.search_repositories(
query='public-query', per_page=20, sort='updated', order='asc', public=True
)
# Verify no request was made since it's not a valid URL
mock_request.assert_not_called()
# Verify we got empty list
assert len(repositories) == 0
@pytest.mark.asyncio
async def test_gitlab_search_repositories_url_parsing():
"""Test that search_repositories correctly parses GitLab URLs when public=True."""
service = GitLabService(token=SecretStr('test-token'))
# Test URL parsing method directly
assert service._parse_gitlab_url('https://gitlab.com/group/repo') == 'group/repo'
assert (
service._parse_gitlab_url('https://gitlab.com/group/subgroup/repo')
== 'group/subgroup/repo'
)
assert (
service._parse_gitlab_url('https://gitlab.example.com/org/team/project')
== 'org/team/project'
)
assert service._parse_gitlab_url('https://gitlab.com/group/repo/') == 'group/repo'
assert (
service._parse_gitlab_url('https://gitlab.com/group/') is None
) # Missing repo
assert service._parse_gitlab_url('https://gitlab.com/') is None # Empty path
assert service._parse_gitlab_url('invalid-url') is None # Invalid URL
@pytest.mark.asyncio
async def test_gitlab_search_repositories_public_url_lookup():
"""Test that search_repositories looks up specific repository when public=True."""
service = GitLabService(token=SecretStr('test-token'))
# Mock repository data
with patch.object(
service, 'get_repository_details_from_repo_name'
) as mock_get_repo:
mock_get_repo.return_value = Repository(
id='123',
full_name='group/repo',
stargazers_count=50,
git_provider=ProviderType.GITLAB,
is_public=True,
owner_type=OwnerType.ORGANIZATION,
)
# Test with valid GitLab URL
repositories = await service.search_repositories(
query='https://gitlab.com/group/repo', public=True
)
# Verify the repository lookup was called with correct path
mock_get_repo.assert_called_once_with('group/repo')
# Verify we got the expected repository
assert len(repositories) == 1
assert repositories[0].full_name == 'group/repo'
@pytest.mark.asyncio
async def test_gitlab_search_repositories_public_url_lookup_with_subgroup():
"""Test that search_repositories handles subgroups correctly when public=True."""
service = GitLabService(token=SecretStr('test-token'))
with patch.object(
service, 'get_repository_details_from_repo_name'
) as mock_get_repo:
mock_get_repo.return_value = Repository(
id='456',
full_name='group/subgroup/repo',
stargazers_count=25,
git_provider=ProviderType.GITLAB,
is_public=True,
owner_type=OwnerType.ORGANIZATION,
)
# Test with GitLab URL containing subgroup
repositories = await service.search_repositories(
query='https://gitlab.example.com/group/subgroup/repo', public=True
)
# Verify the repository lookup was called with correct path
mock_get_repo.assert_called_once_with('group/subgroup/repo')
# Verify we got the expected repository
assert len(repositories) == 1
assert repositories[0].full_name == 'group/subgroup/repo'
@pytest.mark.asyncio
async def test_gitlab_search_repositories_public_url_not_found():
"""Test that search_repositories returns empty list when repository doesn't exist."""
service = GitLabService(token=SecretStr('test-token'))
with patch.object(
service, 'get_repository_details_from_repo_name'
) as mock_get_repo:
# Simulate repository not found
mock_get_repo.side_effect = Exception('Repository not found')
# Test with valid GitLab URL but non-existent repository
# The current implementation doesn't catch exceptions, so we expect it to be raised
with pytest.raises(Exception, match='Repository not found'):
await service.search_repositories(
query='https://gitlab.com/nonexistent/repo', public=True
)
# Verify the repository lookup was attempted
mock_get_repo.assert_called_once_with('nonexistent/repo')
@pytest.mark.asyncio
async def test_gitlab_search_repositories_public_invalid_url():
"""Test that search_repositories returns empty list for invalid URLs."""
service = GitLabService(token=SecretStr('test-token'))
with patch.object(
service, 'get_repository_details_from_repo_name'
) as mock_get_repo:
# Test with invalid URL
repositories = await service.search_repositories(
query='invalid-url', public=True
)
# Verify no repository lookup was attempted
mock_get_repo.assert_not_called()
# Verify we got empty list
assert len(repositories) == 0
@pytest.mark.asyncio
async def test_gitlab_search_repositories_formats_search_query():
"""Test that search_repositories properly formats search queries with multiple terms."""
service = GitLabService(token=SecretStr('test-token'))
# Mock repository data
mock_repos = [
{
'id': 123,
'path_with_namespace': 'group/repo',
'star_count': 50,
'visibility': 'private',
'namespace': {'kind': 'group'},
},
]
with patch.object(service, '_make_request') as mock_request:
mock_request.return_value = (mock_repos, {})
# Test search with multiple terms (should format with + separator)
repositories = await service.search_repositories(
query='my project name', public=False
)
# Verify the request was made with correct parameters
mock_request.assert_called_once()
call_args = mock_request.call_args
url = call_args[0][0]
params = call_args[0][1]
assert url == f'{service.BASE_URL}/projects'
assert (
params['search'] == 'my project name'
) # Current implementation doesn't format spaces
assert params['membership'] is True
assert params['search_namespaces'] is True # Added by implementation
# Verify we got the expected repositories
assert len(repositories) == 1
@pytest.mark.asyncio
async def test_gitlab_search_repositories_single_term_query():
"""Test that search_repositories handles single term queries correctly."""
service = GitLabService(token=SecretStr('test-token'))
# Mock repository data
mock_repos = [
{
'id': 456,
'path_with_namespace': 'user/single-repo',
'star_count': 25,
'visibility': 'private',
'namespace': {'kind': 'user'},
},
]
with patch.object(service, '_make_request') as mock_request:
mock_request.return_value = (mock_repos, {})
# Test search with single term (should remain unchanged)
repositories = await service.search_repositories(
query='singleterm', public=False
)
# Verify the request was made with correct parameters
mock_request.assert_called_once()
call_args = mock_request.call_args
params = call_args[0][1]
assert params['search'] == 'singleterm' # No change for single term
# Verify we got the expected repositories
assert len(repositories) == 1

View File

@@ -1,20 +1,11 @@
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import ValidationError
from openhands.controller.agent import Agent
from openhands.core.config import OpenHandsConfig, load_from_env
from openhands.core.config.mcp_config import (
MCPConfig,
MCPSHTTPServerConfig,
MCPSSEServerConfig,
MCPStdioServerConfig,
)
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.server.session.session import Session
from openhands.storage.memory import InMemoryFileStore
def test_valid_sse_config():
@@ -279,174 +270,3 @@ def test_mcp_stdio_server_args_parsing_invalid_quotes():
name='test-server', command='python', args='--config "unmatched quote'
)
assert 'Invalid argument format' in str(exc_info.value)
def test_env_var_mcp_shttp_server_config(monkeypatch):
"""Test creating MCPSHTTPServerConfig from environment variables."""
# Set environment variables for MCP HTTP server
monkeypatch.setenv(
'MCP_SHTTP_SERVERS',
'[{"url": "http://env-server:8080", "api_key": "env-api-key"}]',
)
# Create a config object
config = OpenHandsConfig()
# Load from environment
load_from_env(config, os.environ)
# Convert dictionary servers to proper server config objects by creating a new MCPConfig
# This triggers the model validator which automatically converts dict servers to proper objects
config.mcp = MCPConfig(
sse_servers=config.mcp.sse_servers,
stdio_servers=config.mcp.stdio_servers,
shttp_servers=config.mcp.shttp_servers,
)
# Check that the HTTP server was added
assert len(config.mcp.shttp_servers) == 1
# Access the first server
server = config.mcp.shttp_servers[0]
# Verify it's a proper server config object
assert isinstance(server, MCPSHTTPServerConfig)
assert server.url == 'http://env-server:8080'
assert server.api_key == 'env-api-key'
# Now let's create a proper MCPConfig with the values from the environment
mcp_config = MCPConfig(shttp_servers=config.mcp.shttp_servers)
# Verify that the MCPSHTTPServerConfig objects are created correctly
assert len(mcp_config.shttp_servers) == 1
assert isinstance(mcp_config.shttp_servers[0], MCPSHTTPServerConfig)
assert mcp_config.shttp_servers[0].url == 'http://env-server:8080'
assert mcp_config.shttp_servers[0].api_key == 'env-api-key'
def test_env_var_mcp_shttp_server_config_with_toml(monkeypatch, tmp_path):
"""Test creating MCPSHTTPServerConfig from environment variables with TOML config."""
# Create a TOML file with some MCP configuration
toml_file = tmp_path / 'config.toml'
with open(toml_file, 'w', encoding='utf-8') as f:
f.write("""
[mcp]
sse_servers = ["http://toml-server:8080"]
shttp_servers = [
{ url = "http://toml-http-server:8080", api_key = "toml-api-key" }
]
""")
# Set environment variables for MCP HTTP server to override TOML
monkeypatch.setenv(
'MCP_SHTTP_SERVERS',
'[{"url": "http://env-server:8080", "api_key": "env-api-key"}]',
)
# Create a config object
config = OpenHandsConfig()
# Load from TOML first
from openhands.core.config import load_from_toml
load_from_toml(config, str(toml_file))
# Verify TOML values were loaded
assert len(config.mcp.shttp_servers) == 1
assert isinstance(config.mcp.shttp_servers[0], MCPSHTTPServerConfig)
assert config.mcp.shttp_servers[0].url == 'http://toml-http-server:8080'
assert config.mcp.shttp_servers[0].api_key == 'toml-api-key'
# Now load from environment, which should override TOML
load_from_env(config, os.environ)
# Check that the environment values override the TOML values
assert len(config.mcp.shttp_servers) == 1
# The values should now be from the environment
server = config.mcp.shttp_servers[0]
assert isinstance(server, MCPSHTTPServerConfig)
assert server.url == 'http://env-server:8080'
assert server.api_key == 'env-api-key'
def test_env_var_mcp_shttp_servers_with_python_str_representation(monkeypatch):
"""Test creating MCPSHTTPServerConfig from environment variables using Python string representation."""
# Create a Python list of dictionaries
mcp_shttp_servers = [
{'url': 'https://example.com/mcp/mcp', 'api_key': 'test-api-key'}
]
# Set environment variable with the string representation of the Python list
monkeypatch.setenv('MCP_SHTTP_SERVERS', str(mcp_shttp_servers))
# Create a config object
config = OpenHandsConfig()
# Load from environment
load_from_env(config, os.environ)
# Check that the HTTP server was added
assert len(config.mcp.shttp_servers) == 1
# Access the first server
server = config.mcp.shttp_servers[0]
# Check that it's a dict with the expected keys
assert isinstance(server, MCPSHTTPServerConfig)
assert server.url == 'https://example.com/mcp/mcp'
assert server.api_key == 'test-api-key'
@pytest.mark.asyncio
async def test_session_preserves_env_mcp_config(monkeypatch):
"""Test that Session preserves MCP configuration from environment variables."""
# Set environment variables for MCP HTTP server
monkeypatch.setenv(
'MCP_SHTTP_SERVERS',
'[{"url": "http://env-server:8080", "api_key": "env-api-key"}]',
)
# Also set MCP_HOST to prevent the default server from being added
monkeypatch.setenv('MCP_HOST', 'dummy')
# Create a config object and load from environment
config = OpenHandsConfig()
load_from_env(config, os.environ)
# Verify the environment variables were loaded into the config
assert config.mcp_host == 'dummy'
assert len(config.mcp.shttp_servers) == 1
# If it's already a proper server config object, just verify it
assert isinstance(config.mcp.shttp_servers[0], MCPSHTTPServerConfig)
assert config.mcp.shttp_servers[0].url == 'http://env-server:8080'
assert config.mcp.shttp_servers[0].api_key == 'env-api-key'
# Create a session with the config
session = Session(
sid='test-sid',
file_store=InMemoryFileStore({}),
config=config,
sio=AsyncMock(),
)
# Create empty settings
settings = ConversationInitData()
# Mock the Agent.get_cls method to avoid AgentNotRegisteredError
mock_agent_cls = MagicMock()
mock_agent_instance = MagicMock()
mock_agent_cls.return_value = mock_agent_instance
# Initialize the agent (this is where the MCP config would be reset)
with (
patch.object(session.agent_session, 'start', AsyncMock()),
patch.object(Agent, 'get_cls', return_value=mock_agent_cls),
):
await session.initialize_agent(settings, None, None)
# Verify that the MCP configuration was preserved
assert len(session.config.mcp.shttp_servers) >= 0
# Clean up
await session.close()

View File

@@ -70,21 +70,7 @@ async def test_mixed_connection_results():
# Create a successful client
successful_client = mock.MagicMock(spec=MCPClient)
# Create a mock tool with a to_param method that returns a tool dictionary
mock_tool = mock.MagicMock()
mock_tool.name = 'mock_tool'
mock_tool.to_param.return_value = {
'type': 'function',
'function': {
'name': 'mock_tool',
'description': 'A mock tool for testing',
'parameters': {},
},
}
# Set the client's tools
successful_client.tools = [mock_tool]
successful_client.tools = [mock.MagicMock()]
# Mock create_mcp_clients to return our successful client
with mock.patch(
@@ -95,4 +81,3 @@ async def test_mixed_connection_results():
# Verify that tools were returned
assert len(tools) > 0
assert tools[0]['function']['name'] == 'mock_tool'