mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
15 Commits
implement-
...
fix/git-ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80f1da66f1 | ||
|
|
50cd559cd4 | ||
|
|
dc2f5cd1b0 | ||
|
|
07041e057d | ||
|
|
6e91d19f80 | ||
|
|
936510e219 | ||
|
|
7af35ab827 | ||
|
|
a7245f2de2 | ||
|
|
6d7ab8a022 | ||
|
|
bbfa37fd97 | ||
|
|
d0cf12e474 | ||
|
|
78306b1ee7 | ||
|
|
f6d99234f1 | ||
|
|
19ca52f954 | ||
|
|
df75116184 |
@@ -117,6 +117,7 @@ def get_config(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
|
||||
@@ -345,6 +345,7 @@ def get_config(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
|
||||
@@ -226,6 +226,7 @@ def get_config(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
|
||||
@@ -203,6 +203,7 @@ def get_config(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
|
||||
@@ -164,6 +164,7 @@ def get_config(
|
||||
default_agent=metadata.agent_class,
|
||||
run_as_openhands=False,
|
||||
max_iterations=metadata.max_iterations,
|
||||
enable_browser=RUN_WITH_BROWSING,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox=sandbox_config,
|
||||
# do not mount workspace
|
||||
|
||||
@@ -19,7 +19,13 @@ describe("AuthModal", () => {
|
||||
});
|
||||
|
||||
it("should render the GitHub and GitLab buttons", () => {
|
||||
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
|
||||
render(
|
||||
<AuthModal
|
||||
githubAuthUrl="mock-url"
|
||||
appMode="saas"
|
||||
providersConfigured={["github", "gitlab"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const githubButton = screen.getByRole("button", {
|
||||
name: "GITHUB$CONNECT_TO_GITHUB",
|
||||
@@ -35,7 +41,13 @@ describe("AuthModal", () => {
|
||||
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockUrl = "https://github.com/login/oauth/authorize";
|
||||
render(<AuthModal githubAuthUrl={mockUrl} appMode="saas" />);
|
||||
render(
|
||||
<AuthModal
|
||||
githubAuthUrl={mockUrl}
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const githubButton = screen.getByRole("button", {
|
||||
name: "GITHUB$CONNECT_TO_GITHUB",
|
||||
@@ -52,7 +64,6 @@ describe("AuthModal", () => {
|
||||
const termsSection = screen.getByTestId("auth-modal-terms-of-service");
|
||||
expect(termsSection).toBeInTheDocument();
|
||||
|
||||
|
||||
// Check that all text content is present in the paragraph
|
||||
expect(termsSection).toHaveTextContent(
|
||||
"AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
|
||||
|
||||
@@ -16,8 +16,6 @@ import { ConversationCard } from "#/components/features/conversation-panel/conve
|
||||
import { clickOnEditButton } from "./utils";
|
||||
|
||||
// We'll use the actual i18next implementation but override the translation function
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
|
||||
// Mock the t function to return our custom translations
|
||||
vi.mock("react-i18next", async () => {
|
||||
@@ -124,7 +122,8 @@ describe("ConversationCard", () => {
|
||||
|
||||
it("should toggle a context menu when clicking the ellipsis button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
const onContextMenuToggle = vi.fn();
|
||||
const { rerender } = renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
@@ -132,6 +131,8 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen={false}
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -140,15 +141,32 @@ describe("ConversationCard", () => {
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
expect(onContextMenuToggle).toHaveBeenCalledWith(true);
|
||||
|
||||
// Simulate context menu being opened by parent
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("context-menu");
|
||||
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
|
||||
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should call onDelete when the delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -157,18 +175,18 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const deleteButton = within(menu).getByTestId("delete-button");
|
||||
|
||||
await user.click(deleteButton);
|
||||
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
|
||||
@@ -198,7 +216,11 @@ describe("ConversationCard", () => {
|
||||
|
||||
test("conversation title should call onChangeTitle when changed and blurred", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
let menuOpen = true;
|
||||
const onContextMenuToggle = vi.fn((isOpen: boolean) => {
|
||||
menuOpen = isOpen;
|
||||
});
|
||||
const { rerender } = renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
@@ -206,10 +228,27 @@ describe("ConversationCard", () => {
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
onChangeTitle={onChangeTitle}
|
||||
contextMenuOpen={menuOpen}
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
await clickOnEditButton(user);
|
||||
|
||||
// Re-render with updated state
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
onChangeTitle={onChangeTitle}
|
||||
contextMenuOpen={menuOpen}
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const title = screen.getByTestId("conversation-card-title");
|
||||
|
||||
expect(title).toBeEnabled();
|
||||
@@ -227,6 +266,7 @@ describe("ConversationCard", () => {
|
||||
|
||||
it("should reset title and not call onChangeTitle when the title is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -235,6 +275,8 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -271,6 +313,7 @@ describe("ConversationCard", () => {
|
||||
|
||||
test("clicking the title should not trigger the onClick handler if edit mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -279,6 +322,8 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -292,6 +337,7 @@ describe("ConversationCard", () => {
|
||||
|
||||
test("clicking the delete button should not trigger the onClick handler", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -300,12 +346,11 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const deleteButton = within(menu).getByTestId("delete-button");
|
||||
|
||||
@@ -315,7 +360,7 @@ describe("ConversationCard", () => {
|
||||
});
|
||||
|
||||
it("should show display cost button only when showOptions is true", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
const { rerender } = renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -324,21 +369,17 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Wait for context menu to appear
|
||||
const menu = await screen.findByTestId("context-menu");
|
||||
expect(
|
||||
within(menu).queryByTestId("display-cost-button"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Close menu
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -348,12 +389,11 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open menu again
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Wait for context menu to appear and check for display cost button
|
||||
const newMenu = await screen.findByTestId("context-menu");
|
||||
within(newMenu).getByTestId("display-cost-button");
|
||||
@@ -361,6 +401,7 @@ describe("ConversationCard", () => {
|
||||
|
||||
it("should show metrics modal when clicking the display cost button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
@@ -370,12 +411,11 @@ describe("ConversationCard", () => {
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
showOptions
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const displayCostButton = within(menu).getByTestId("display-cost-button");
|
||||
|
||||
@@ -386,7 +426,7 @@ describe("ConversationCard", () => {
|
||||
});
|
||||
|
||||
it("should not display the edit or delete options if the handler is not provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onContextMenuToggle = vi.fn();
|
||||
const { rerender } = renderWithProviders(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
@@ -394,19 +434,15 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const menu = await screen.findByTestId("context-menu");
|
||||
expect(within(menu).queryByTestId("edit-button")).toBeInTheDocument();
|
||||
expect(within(menu).queryByTestId("delete-button")).not.toBeInTheDocument();
|
||||
|
||||
// toggle to hide the context menu
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
@@ -414,10 +450,11 @@ describe("ConversationCard", () => {
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
contextMenuOpen
|
||||
onContextMenuToggle={onContextMenuToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(ellipsisButton);
|
||||
const newMenu = await screen.findByTestId("context-menu");
|
||||
expect(
|
||||
within(newMenu).queryByTestId("edit-button"),
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientConfig } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import React from "react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import MicroagentManagement from "#/routes/microagent-management";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
|
||||
describe("MicroagentManagement", () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: MicroagentManagement,
|
||||
path: "/",
|
||||
},
|
||||
]);
|
||||
|
||||
const renderMicroagentManagement = (config?: QueryClientConfig) =>
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagent: null,
|
||||
addMicroagentModalVisible: false,
|
||||
selectedRepository: null,
|
||||
personalRepositories: [],
|
||||
organizationRepositories: [],
|
||||
repositories: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock("react-router", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-router")>()),
|
||||
Link: ({ children }: React.PropsWithChildren) => children,
|
||||
useNavigate: vi.fn(() => vi.fn()),
|
||||
useLocation: vi.fn(() => ({ pathname: "/microagent-management" })),
|
||||
useParams: vi.fn(() => ({ conversationId: "2" })),
|
||||
}));
|
||||
});
|
||||
|
||||
const mockRepositories: GitRepository[] = [
|
||||
{
|
||||
id: "1",
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-01T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "user/repo2/.openhands",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-02T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
full_name: "org/repo3/.openhands",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "organization",
|
||||
pushed_at: "2021-10-03T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
full_name: "user/repo4",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-04T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
full_name: "user/TestRepository",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-05T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
full_name: "org/AnotherRepo",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "organization",
|
||||
pushed_at: "2021-10-06T12:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const mockMicroagents: RepositoryMicroagent[] = [
|
||||
{
|
||||
name: "test-microagent-1",
|
||||
type: "repo",
|
||||
content: "Test microagent content 1",
|
||||
triggers: ["test", "microagent"],
|
||||
inputs: [],
|
||||
tools: [],
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
name: "test-microagent-2",
|
||||
type: "knowledge",
|
||||
content: "Test microagent content 2",
|
||||
triggers: ["knowledge", "test"],
|
||||
inputs: [],
|
||||
tools: [],
|
||||
created_at: "2021-10-02T12:00:00Z",
|
||||
git_provider: "github",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
// Setup default mock for retrieveUserGitRepositories
|
||||
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue([
|
||||
...mockRepositories,
|
||||
]);
|
||||
// Setup default mock for getRepositoryMicroagents
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagents").mockResolvedValue([
|
||||
...mockMicroagents,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should render the microagent management page", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Check that the main title is rendered
|
||||
await screen.findByText("MICROAGENT_MANAGEMENT$DESCRIPTION");
|
||||
});
|
||||
|
||||
it("should display loading state when fetching repositories", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockImplementation(
|
||||
() => new Promise(() => {}), // Never resolves
|
||||
);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Check that loading spinner is displayed
|
||||
const loadingSpinner = await screen.findByText("HOME$LOADING_REPOSITORIES");
|
||||
expect(loadingSpinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle error when fetching repositories", async () => {
|
||||
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(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should categorize repositories correctly", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that tabs are rendered
|
||||
const personalTab = screen.getByText("COMMON$PERSONAL");
|
||||
const repositoriesTab = screen.getByText("COMMON$REPOSITORIES");
|
||||
const organizationsTab = screen.getByText("COMMON$ORGANIZATIONS");
|
||||
|
||||
expect(personalTab).toBeInTheDocument();
|
||||
expect(repositoriesTab).toBeInTheDocument();
|
||||
expect(organizationsTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display repositories in accordion", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded and rendered
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that repository names are displayed
|
||||
const repo1 = screen.getByText("user/repo2/.openhands");
|
||||
expect(repo1).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should expand repository accordion and show microagents", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
const repoAccordion = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Wait for microagents to be fetched
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
|
||||
"user",
|
||||
"repo2",
|
||||
);
|
||||
});
|
||||
|
||||
// Check that microagents are displayed
|
||||
const microagent1 = screen.getByText("test-microagent-1");
|
||||
const microagent2 = screen.getByText("test-microagent-2");
|
||||
|
||||
expect(microagent1).toBeInTheDocument();
|
||||
expect(microagent2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display loading state when fetching microagents", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getRepositoryMicroagentsSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"getRepositoryMicroagents",
|
||||
);
|
||||
getRepositoryMicroagentsSpy.mockImplementation(
|
||||
() => new Promise(() => {}), // Never resolves
|
||||
);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
const repoAccordion = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Check that loading spinner is displayed
|
||||
const loadingSpinner = screen.getByTestId("loading-spinner");
|
||||
expect(loadingSpinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle error when fetching microagents", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getRepositoryMicroagentsSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"getRepositoryMicroagents",
|
||||
);
|
||||
getRepositoryMicroagentsSpy.mockRejectedValue(
|
||||
new Error("Failed to fetch microagents"),
|
||||
);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
const repoAccordion = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Wait for the error to be handled
|
||||
await waitFor(() => {
|
||||
expect(getRepositoryMicroagentsSpy).toHaveBeenCalledWith("user", "repo2");
|
||||
});
|
||||
});
|
||||
|
||||
it("should display empty state when no microagents are found", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getRepositoryMicroagentsSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"getRepositoryMicroagents",
|
||||
);
|
||||
getRepositoryMicroagentsSpy.mockResolvedValue([]);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
const repoAccordion = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Wait for microagents to be fetched
|
||||
await waitFor(() => {
|
||||
expect(getRepositoryMicroagentsSpy).toHaveBeenCalledWith("user", "repo2");
|
||||
});
|
||||
|
||||
// Check that the learn this repo component is displayed
|
||||
const learnThisRepo = screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$LEARN_THIS_REPO",
|
||||
);
|
||||
expect(learnThisRepo).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display microagent cards with correct information", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
const repoAccordion = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Wait for microagents to be fetched
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
|
||||
"user",
|
||||
"repo2",
|
||||
);
|
||||
});
|
||||
|
||||
// Check that microagent cards display correct information
|
||||
const microagent1 = screen.getByText("test-microagent-1");
|
||||
const microagent2 = screen.getByText("test-microagent-2");
|
||||
|
||||
expect(microagent1).toBeInTheDocument();
|
||||
expect(microagent2).toBeInTheDocument();
|
||||
|
||||
// Check that microagent file paths are displayed
|
||||
const filePath1 = screen.getByText(
|
||||
".openhands/microagents/test-microagent-1",
|
||||
);
|
||||
const filePath2 = screen.getByText(
|
||||
".openhands/microagents/test-microagent-2",
|
||||
);
|
||||
|
||||
expect(filePath1).toBeInTheDocument();
|
||||
expect(filePath2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display add microagent button in repository accordion", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that add microagent buttons are present
|
||||
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
|
||||
expect(addButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should open add microagent modal when add button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
|
||||
await user.click(addButtons[0]);
|
||||
|
||||
// Check that the modal is opened
|
||||
const modalTitle = screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT",
|
||||
);
|
||||
expect(modalTitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should close add microagent modal when cancel is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
|
||||
await user.click(addButtons[0]);
|
||||
|
||||
// Check that the modal is opened
|
||||
const modalTitle = screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT",
|
||||
);
|
||||
expect(modalTitle).toBeInTheDocument();
|
||||
|
||||
// Find and click the cancel button
|
||||
const cancelButton = screen.getByRole("button", { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Check that the modal is closed
|
||||
expect(
|
||||
screen.queryByText("MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display empty state when no repositories are found", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue([]);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that empty state messages are displayed
|
||||
const personalEmptyState = screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS",
|
||||
);
|
||||
|
||||
expect(personalEmptyState).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle multiple repository expansions", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
const repoAccordion1 = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion1);
|
||||
|
||||
// Wait for microagents to be fetched for first repo
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
|
||||
"user",
|
||||
"repo2",
|
||||
);
|
||||
});
|
||||
|
||||
// Check that the API call was made
|
||||
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should display ready to add microagent message in main area", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Check that the main area shows the ready message
|
||||
const readyMessage = screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT",
|
||||
);
|
||||
const descriptionMessage = screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES",
|
||||
);
|
||||
|
||||
expect(readyMessage).toBeInTheDocument();
|
||||
expect(descriptionMessage).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Search functionality tests
|
||||
describe("Search functionality", () => {
|
||||
it("should render search input field", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that search input is rendered
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
expect(searchInput).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Search repositories...",
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter repositories when typing in search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Initially only repositories with .openhands should be visible
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
|
||||
// Type in search input to filter further
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, "repo2");
|
||||
|
||||
// Only repo2 should be visible
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should perform case-insensitive search", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with uppercase
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, "REPO2");
|
||||
|
||||
// repo2 should be visible (case-insensitive match)
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should filter repositories by partial matches", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with partial match
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, "repo");
|
||||
|
||||
// All repositories with "repo" in the name should be visible
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show all repositories when search input is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, "repo2");
|
||||
|
||||
// Only repo2 should be visible
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
|
||||
// Clear the search input
|
||||
await user.clear(searchInput);
|
||||
|
||||
// All repositories should be visible again (only those with .openhands)
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle empty search results", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with non-existent repository name
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, "nonexistent");
|
||||
|
||||
// No repositories should be visible
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("user/repo2/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle special characters in search", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with special characters
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, ".openhands");
|
||||
|
||||
// Only repositories with .openhands should be visible
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maintain accordion functionality with filtered results", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Filter to show only repo2
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, "repo2");
|
||||
|
||||
// Click on the filtered repository accordion
|
||||
const repoAccordion = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Wait for microagents to be fetched
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
|
||||
"user",
|
||||
"repo2",
|
||||
);
|
||||
});
|
||||
|
||||
// Check that microagents are displayed
|
||||
const microagent1 = screen.getByText("test-microagent-1");
|
||||
const microagent2 = screen.getByText("test-microagent-2");
|
||||
|
||||
expect(microagent1).toBeInTheDocument();
|
||||
expect(microagent2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle whitespace in search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with leading/trailing whitespace
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, " repo2 ");
|
||||
|
||||
// repo2 should still be visible (whitespace should be trimmed)
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should update search results in real-time", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
|
||||
// Type "repo" - should show repo2
|
||||
await user.type(searchInput, "repo");
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
|
||||
// Add "2" to make it "repo2" - should show only repo2
|
||||
await user.type(searchInput, "2");
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
|
||||
// Remove "2" to make it "repo" again - should show repo2
|
||||
await user.keyboard("{Backspace}");
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
44
frontend/package-lock.json
generated
44
frontend/package-lock.json
generated
@@ -34,7 +34,7 @@
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.525.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.257.0",
|
||||
"posthog-js": "^1.257.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -68,7 +68,7 @@
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.0.14",
|
||||
"@types/node": "^24.0.15",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -82,11 +82,11 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-i18next": "^6.1.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-i18next": "^6.1.3",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
"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",
|
||||
@@ -6160,9 +6160,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz",
|
||||
"integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==",
|
||||
"version": "24.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.15.tgz",
|
||||
"integrity": "sha512-oaeTSbCef7U/z7rDeJA138xpG3NuKc64/rZ2qmUFkFJmnMsAPaluIifqyWd8hSSMxyP9oie3dLAqYPblag9KgA==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.8.0"
|
||||
@@ -9017,11 +9017,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-prettier": {
|
||||
"version": "10.1.5",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz",
|
||||
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
|
||||
"version": "10.1.8",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -9083,11 +9082,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-i18next": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.2.tgz",
|
||||
"integrity": "sha512-hvTmws4kouNHkk314+9MHNj+RQmsqrkejWhTXGlRC0j8H+EXq2qDRLe6UqIjrFZo7/ogyd4btuqsnKCBi8wHbw==",
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.3.tgz",
|
||||
"integrity": "sha512-z/h4oBRd9wI1ET60HqcLSU6XPeAh/EPOrBBTyCdkWeMoYrWAaUVA+DOQkWTiNIyCltG4NTmy62SQisVXxoXurw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"requireindex": "~1.1.0"
|
||||
@@ -9252,11 +9250,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier": {
|
||||
"version": "5.5.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.1.tgz",
|
||||
"integrity": "sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==",
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.3.tgz",
|
||||
"integrity": "sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prettier-linter-helpers": "^1.0.0",
|
||||
"synckit": "^0.11.7"
|
||||
@@ -14268,10 +14265,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.257.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.257.0.tgz",
|
||||
"integrity": "sha512-Ujg9RGtWVCu+4tmlRpALSy2ZOZI6JtieSYXIDDdgMWm167KYKvTtbMPHdoBaPWcNu0Km+1hAIBnQFygyn30KhA==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"version": "1.257.1",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.257.1.tgz",
|
||||
"integrity": "sha512-29kk3IO/LkPQ8E1cds6a2sWr5iN4BovgL+EMzRK9hQXbI6D3FJnQ7zLU6EUpktt6pHnqGpfO3BTEcflcDYkHBg==",
|
||||
"dependencies": {
|
||||
"core-js": "^3.38.1",
|
||||
"fflate": "^0.4.8",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.525.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.257.0",
|
||||
"posthog-js": "^1.257.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -92,7 +92,7 @@
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.0.14",
|
||||
"@types/node": "^24.0.15",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -106,11 +106,11 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-i18next": "^6.1.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-i18next": "^6.1.3",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
"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",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import { GitUser, GitRepository, Branch } from "#/types/git";
|
||||
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
|
||||
class OpenHands {
|
||||
private static currentConversation: Conversation | null = null;
|
||||
@@ -464,6 +465,22 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available microagents for a specific repository
|
||||
* @param owner The repository owner
|
||||
* @param repo The repository name
|
||||
* @returns The available microagents for the repository
|
||||
*/
|
||||
static async getRepositoryMicroagents(
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<RepositoryMicroagent[]> {
|
||||
const { data } = await openHands.get<RepositoryMicroagent[]>(
|
||||
`/api/user/repository/${owner}/${repo}/microagents`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getMicroagentPrompt(
|
||||
conversationId: string,
|
||||
eventId: number,
|
||||
@@ -489,24 +506,6 @@ class OpenHands {
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GitHub user installation IDs
|
||||
* @returns List of GitHub installation IDs
|
||||
*/
|
||||
static async getGitHubUserInstallationIds(): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>("/github/installations");
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the BitBucket workspaces
|
||||
* @returns List of BitBucket workspaces
|
||||
*/
|
||||
static async getBitBucketWorkspaces(): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>("/bitbucket/installations");
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -13,6 +13,7 @@ interface ControlsProps {
|
||||
|
||||
export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:items-center md:justify-between md:flex-row">
|
||||
@@ -37,6 +38,8 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
|
||||
}}
|
||||
conversationStatus={conversation?.status}
|
||||
conversationId={conversation?.conversation_id}
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuToggle={setContextMenuOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,8 @@ interface ConversationCardProps {
|
||||
conversationStatus?: ConversationStatus;
|
||||
variant?: "compact" | "default";
|
||||
conversationId?: string; // Optional conversation ID for VS Code URL
|
||||
contextMenuOpen?: boolean;
|
||||
onContextMenuToggle?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
|
||||
@@ -55,10 +57,11 @@ export function ConversationCard({
|
||||
conversationStatus = "STOPPED",
|
||||
variant = "default",
|
||||
conversationId,
|
||||
contextMenuOpen = false,
|
||||
onContextMenuToggle,
|
||||
}: ConversationCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { parsedEvents } = useWsClient();
|
||||
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
|
||||
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
|
||||
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
|
||||
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
|
||||
@@ -101,21 +104,21 @@ export function ConversationCard({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onDelete?.();
|
||||
setContextMenuVisible(false);
|
||||
onContextMenuToggle?.(false);
|
||||
};
|
||||
|
||||
const handleStop = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onStop?.();
|
||||
setContextMenuVisible(false);
|
||||
onContextMenuToggle?.(false);
|
||||
};
|
||||
|
||||
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setTitleMode("edit");
|
||||
setContextMenuVisible(false);
|
||||
onContextMenuToggle?.(false);
|
||||
};
|
||||
|
||||
const handleDownloadViaVSCode = async (
|
||||
@@ -141,7 +144,7 @@ export function ConversationCard({
|
||||
}
|
||||
}
|
||||
|
||||
setContextMenuVisible(false);
|
||||
onContextMenuToggle?.(false);
|
||||
};
|
||||
|
||||
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -224,15 +227,15 @@ export function ConversationCard({
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setContextMenuVisible((prev) => !prev);
|
||||
onContextMenuToggle?.(!contextMenuOpen);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
{contextMenuVisible && (
|
||||
{contextMenuOpen && (
|
||||
<ConversationCardContextMenu
|
||||
onClose={() => setContextMenuVisible(false)}
|
||||
onClose={() => onContextMenuToggle?.(false)}
|
||||
onDelete={onDelete && handleDelete}
|
||||
onStop={
|
||||
conversationStatus !== "STOPPED"
|
||||
|
||||
@@ -36,6 +36,9 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
const [selectedConversationId, setSelectedConversationId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [openContextMenuId, setOpenContextMenuId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const { data: conversations, isFetching, error } = useUserConversations();
|
||||
|
||||
@@ -144,6 +147,10 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
createdAt={project.created_at}
|
||||
conversationStatus={project.status}
|
||||
conversationId={project.conversation_id}
|
||||
contextMenuOpen={openContextMenuId === project.conversation_id}
|
||||
onContextMenuToggle={(isOpen) =>
|
||||
setOpenContextMenuId(isOpen ? project.conversation_id : null)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
@@ -10,9 +10,6 @@ import { BrandButton } from "../settings/brand-button";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { SettingsDropdownInput } from "../settings/settings-dropdown-input";
|
||||
import {
|
||||
RepositoryDropdown,
|
||||
RepositoryLoadingState,
|
||||
@@ -35,10 +32,8 @@ export function RepositorySelectionForm({
|
||||
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedProvider, setSelectedProvider] = React.useState<Provider | null>(null);
|
||||
// Add a ref to track if the branch was manually cleared by the user
|
||||
const branchManuallyClearedRef = React.useRef<boolean>(false);
|
||||
const { providers } = useUserProviders();
|
||||
const {
|
||||
data: repositories,
|
||||
isLoading: isLoadingRepositories,
|
||||
@@ -61,13 +56,6 @@ export function RepositorySelectionForm({
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery);
|
||||
|
||||
// Auto-select provider if there's only one
|
||||
React.useEffect(() => {
|
||||
if (providers.length === 1 && !selectedProvider) {
|
||||
setSelectedProvider(providers[0]);
|
||||
}
|
||||
}, [providers, selectedProvider]);
|
||||
|
||||
// Auto-select main or master branch if it exists, but only if the branch wasn't manually cleared
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -95,10 +83,8 @@ export function RepositorySelectionForm({
|
||||
const isCreatingConversation =
|
||||
isPending || isSuccess || isCreatingConversationElsewhere;
|
||||
|
||||
// Use all repositories without filtering by provider for now
|
||||
const allRepositories = repositories?.concat(searchedRepos || []);
|
||||
|
||||
const repositoriesItems = (allRepositories || []).map((repo) => ({
|
||||
const repositoriesItems = allRepositories?.map((repo) => ({
|
||||
key: repo.id,
|
||||
label: decodeURIComponent(repo.full_name),
|
||||
}));
|
||||
@@ -108,14 +94,6 @@ export function RepositorySelectionForm({
|
||||
label: branch.name,
|
||||
}));
|
||||
|
||||
// Create provider dropdown items
|
||||
const providerItems = React.useMemo(() => {
|
||||
return providers.map(provider => ({
|
||||
key: provider,
|
||||
label: provider.charAt(0).toUpperCase() + provider.slice(1), // Capitalize first letter
|
||||
}));
|
||||
}, [providers]);
|
||||
|
||||
const handleRepoSelection = (key: React.Key | null) => {
|
||||
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
|
||||
if (selectedRepo) onRepoSelection(selectedRepo);
|
||||
@@ -124,14 +102,6 @@ export function RepositorySelectionForm({
|
||||
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
|
||||
};
|
||||
|
||||
const handleProviderSelection = (key: React.Key | null) => {
|
||||
const provider = key as Provider | null;
|
||||
setSelectedProvider(provider);
|
||||
setSelectedRepository(null); // Reset repository selection when provider changes
|
||||
setSelectedBranch(null); // Reset branch selection when provider changes
|
||||
onRepoSelection(null); // Reset parent component's selected repo
|
||||
};
|
||||
|
||||
const handleBranchSelection = (key: React.Key | null) => {
|
||||
const selectedBranchObj = branches?.find((branch) => branch.name === key);
|
||||
setSelectedBranch(selectedBranchObj || null);
|
||||
@@ -163,26 +133,6 @@ export function RepositorySelectionForm({
|
||||
}
|
||||
};
|
||||
|
||||
// Render the provider dropdown
|
||||
const renderProviderSelector = () => {
|
||||
// Only render if there are multiple providers
|
||||
if (providers.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsDropdownInput
|
||||
testId="provider-dropdown"
|
||||
name="provider-dropdown"
|
||||
placeholder="Select Provider"
|
||||
items={providerItems}
|
||||
wrapperClassName="max-w-[500px]"
|
||||
onSelectionChange={handleProviderSelection}
|
||||
selectedKey={selectedProvider || undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the appropriate UI based on the loading/error state
|
||||
const renderRepositorySelector = () => {
|
||||
if (isLoadingRepositories) {
|
||||
@@ -193,15 +143,11 @@ export function RepositorySelectionForm({
|
||||
return <RepositoryErrorState />;
|
||||
}
|
||||
|
||||
// For now, don't disable the repo dropdown based on provider selection
|
||||
const isDisabled = false;
|
||||
|
||||
return (
|
||||
<RepositoryDropdown
|
||||
items={repositoriesItems || []}
|
||||
onSelectionChange={handleRepoSelection}
|
||||
onInputChange={handleRepoInputChange}
|
||||
isDisabled={isDisabled}
|
||||
defaultFilter={(textValue, inputValue) => {
|
||||
if (!inputValue) return true;
|
||||
|
||||
@@ -249,8 +195,8 @@ export function RepositorySelectionForm({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{renderProviderSelector()}
|
||||
{renderRepositorySelector()}
|
||||
|
||||
{renderBranchSelector()}
|
||||
|
||||
<BrandButton
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface RepositoryDropdownProps {
|
||||
onSelectionChange: (key: React.Key | null) => void;
|
||||
onInputChange: (value: string) => void;
|
||||
defaultFilter?: (textValue: string, inputValue: string) => boolean;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function RepositoryDropdown({
|
||||
@@ -16,7 +15,6 @@ export function RepositoryDropdown({
|
||||
onSelectionChange,
|
||||
onInputChange,
|
||||
defaultFilter,
|
||||
isDisabled = false,
|
||||
}: RepositoryDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -24,13 +22,12 @@ export function RepositoryDropdown({
|
||||
<SettingsDropdownInput
|
||||
testId="repo-dropdown"
|
||||
name="repo-dropdown"
|
||||
placeholder={isDisabled ? t("Please select a provider first") : t(I18nKey.REPOSITORY$SELECT_REPO)}
|
||||
placeholder={t(I18nKey.REPOSITORY$SELECT_REPO)}
|
||||
items={items}
|
||||
wrapperClassName="max-w-[500px]"
|
||||
onSelectionChange={onSelectionChange}
|
||||
onInputChange={onInputChange}
|
||||
defaultFilter={defaultFilter}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { GitProviderIcon } from "#/components/shared/git-provider-icon";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
|
||||
|
||||
interface MicroagentManagementAccordionTitleProps {
|
||||
repository: GitRepository;
|
||||
}
|
||||
|
||||
export function MicroagentManagementAccordionTitle({
|
||||
repository,
|
||||
}: MicroagentManagementAccordionTitleProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitProviderIcon gitProvider={repository.git_provider} />
|
||||
<div
|
||||
className="text-white text-base font-normal truncate max-w-[150px]"
|
||||
title={repository.full_name}
|
||||
>
|
||||
{repository.full_name}
|
||||
</div>
|
||||
</div>
|
||||
<MicroagentManagementAddMicroagentButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,8 @@ export function MicroagentManagementAddMicroagentButton() {
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClick = () => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
dispatch(setAddMicroagentModalVisible(!addMicroagentModalVisible));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export interface Microagent {
|
||||
id: string;
|
||||
name: string;
|
||||
repositoryUrl: string;
|
||||
createdAt: string;
|
||||
}
|
||||
import { formatDateMMDDYYYY } from "#/utils/format-time-delta";
|
||||
|
||||
interface MicroagentManagementMicroagentCardProps {
|
||||
microagent: Microagent;
|
||||
microagent: {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function MicroagentManagementMicroagentCard({
|
||||
@@ -17,16 +15,20 @@ export function MicroagentManagementMicroagentCard({
|
||||
}: MicroagentManagementMicroagentCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Format the repository URL to point to the microagent file
|
||||
const microagentFilePath = `.openhands/microagents/${microagent.name}`;
|
||||
|
||||
// Format the createdAt date using MM/DD/YYYY format
|
||||
const formattedCreatedAt = formatDateMMDDYYYY(new Date(microagent.createdAt));
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300">
|
||||
<div className="text-white text-[16px] font-semibold">
|
||||
{microagent.name}
|
||||
</div>
|
||||
<div className="text-white text-sm font-normal">{microagentFilePath}</div>
|
||||
<div className="text-white text-sm font-normal">
|
||||
{microagent.repositoryUrl}
|
||||
</div>
|
||||
<div className="text-white text-sm font-normal">
|
||||
{t(I18nKey.COMMON$CREATED_ON)} {microagent.createdAt}
|
||||
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
|
||||
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
|
||||
|
||||
export function MicroagentManagementMicroagents() {
|
||||
const microagents = [
|
||||
{
|
||||
id: "no-comments",
|
||||
name: "No comments",
|
||||
repositoryUrl: "fairwinds/polaris/Repo Overview",
|
||||
createdAt: "05/30/2025",
|
||||
},
|
||||
{
|
||||
id: "tell-me-a-joke",
|
||||
name: "Tell me a joke",
|
||||
repositoryUrl: ".openhands/microagents/Repo Overview",
|
||||
createdAt: "05/30/2025",
|
||||
},
|
||||
];
|
||||
|
||||
const numberOfMicroagents = microagents.length;
|
||||
|
||||
if (numberOfMicroagents === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-end pb-4">
|
||||
<MicroagentManagementAddMicroagentButton />
|
||||
</div>
|
||||
{microagents.map((microagent) => (
|
||||
<div key={microagent.id} className="pb-4">
|
||||
<MicroagentManagementMicroagentCard microagent={microagent} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { FaCircleInfo } from "react-icons/fa6";
|
||||
|
||||
interface MicroagentManagementNoRepositoriesProps {
|
||||
title: string;
|
||||
documentationUrl: string;
|
||||
}
|
||||
|
||||
export function MicroagentManagementNoRepositories({
|
||||
title,
|
||||
documentationUrl,
|
||||
}: MicroagentManagementNoRepositoriesProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center pt-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-white text-sm font-medium">{title}</h2>
|
||||
<a href={documentationUrl} target="_blank" rel="noopener noreferrer">
|
||||
<FaCircleInfo className="text-primary" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
Microagent,
|
||||
MicroagentManagementMicroagentCard,
|
||||
} from "./microagent-management-microagent-card";
|
||||
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
|
||||
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
|
||||
|
||||
export interface RepoMicroagent {
|
||||
id: string;
|
||||
repositoryName: string;
|
||||
repositoryUrl: string;
|
||||
microagents: Microagent[];
|
||||
}
|
||||
|
||||
interface MicroagentManagementRepoMicroagentProps {
|
||||
repoMicroagent: RepoMicroagent;
|
||||
}
|
||||
|
||||
export function MicroagentManagementRepoMicroagent({
|
||||
repoMicroagent,
|
||||
}: MicroagentManagementRepoMicroagentProps) {
|
||||
const { microagents } = repoMicroagent;
|
||||
const numberOfMicroagents = microagents.length;
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<div className="flex items-center justify-between pb-4">
|
||||
<div className="text-white text-base font-normal">
|
||||
{repoMicroagent.repositoryName}
|
||||
</div>
|
||||
<MicroagentManagementAddMicroagentButton />
|
||||
</div>
|
||||
{numberOfMicroagents === 0 && (
|
||||
<MicroagentManagementLearnThisRepo
|
||||
repositoryUrl={repoMicroagent.repositoryUrl}
|
||||
/>
|
||||
)}
|
||||
{numberOfMicroagents > 0 && (
|
||||
<>
|
||||
{microagents.map((microagent) => (
|
||||
<div key={microagent.id} className="pb-4 last:pb-0">
|
||||
<MicroagentManagementMicroagentCard microagent={microagent} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,69 @@
|
||||
import { MicroagentManagementRepoMicroagent } from "./microagent-management-repo-microagent";
|
||||
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
|
||||
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
|
||||
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
|
||||
export function MicroagentManagementRepoMicroagents() {
|
||||
const repoMicroagents = [
|
||||
{
|
||||
id: "rbren/rss-parser",
|
||||
repositoryName: "rbren/rss-parser",
|
||||
repositoryUrl: "https://github.com/rbren/rss-parser",
|
||||
microagents: [],
|
||||
},
|
||||
{
|
||||
id: "fairwinds/polaris",
|
||||
repositoryName: "fairwinds/polaris",
|
||||
repositoryUrl: "https://github.com/fairwinds/polaris",
|
||||
microagents: [
|
||||
{
|
||||
id: "no-comments",
|
||||
name: "No comments",
|
||||
repositoryUrl: "fairwinds/polaris/Repo Overview",
|
||||
createdAt: "05/30/2025",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
export interface RepoMicroagent {
|
||||
id: string;
|
||||
repositoryName: string;
|
||||
repositoryUrl: string;
|
||||
}
|
||||
|
||||
const numberOfRepoMicroagents = repoMicroagents.length;
|
||||
interface MicroagentManagementRepoMicroagentsProps {
|
||||
repoMicroagent: RepoMicroagent;
|
||||
}
|
||||
|
||||
if (numberOfRepoMicroagents === 0) {
|
||||
return null;
|
||||
export function MicroagentManagementRepoMicroagents({
|
||||
repoMicroagent,
|
||||
}: MicroagentManagementRepoMicroagentsProps) {
|
||||
// Extract owner and repo from repositoryName (format: "owner/repo")
|
||||
const [owner, repo] = repoMicroagent.repositoryName.split("/");
|
||||
|
||||
const {
|
||||
data: microagents,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useRepositoryMicroagents(owner, repo);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pb-4 flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{repoMicroagents.map((repoMicroagent) => (
|
||||
<MicroagentManagementRepoMicroagent
|
||||
key={repoMicroagent.id}
|
||||
repoMicroagent={repoMicroagent}
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<MicroagentManagementLearnThisRepo
|
||||
repositoryUrl={repoMicroagent.repositoryUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const numberOfMicroagents = microagents?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
{numberOfMicroagents === 0 && (
|
||||
<MicroagentManagementLearnThisRepo
|
||||
repositoryUrl={repoMicroagent.repositoryUrl}
|
||||
/>
|
||||
)}
|
||||
{numberOfMicroagents > 0 &&
|
||||
microagents?.map((microagent) => (
|
||||
<div key={microagent.name} className="pb-4 last:pb-0">
|
||||
<MicroagentManagementMicroagentCard
|
||||
microagent={{
|
||||
id: microagent.name,
|
||||
name: microagent.name,
|
||||
createdAt: microagent.created_at,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Accordion, AccordionItem } from "@heroui/react";
|
||||
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { getGitProviderBaseUrl, cn } from "#/utils/utils";
|
||||
import { TabType } from "#/types/microagent-management";
|
||||
import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { DOCUMENTATION_URL } from "#/utils/constants";
|
||||
import { MicroagentManagementAccordionTitle } from "./microagent-management-accordion-title";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
|
||||
type MicroagentManagementRepositoriesProps = {
|
||||
repositories: GitRepository[];
|
||||
tabType: TabType;
|
||||
};
|
||||
|
||||
export function MicroagentManagementRepositories({
|
||||
repositories,
|
||||
tabType,
|
||||
}: MicroagentManagementRepositoriesProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const numberOfRepoMicroagents = repositories.length;
|
||||
|
||||
// Filter repositories based on search query
|
||||
const filteredRepositories = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return repositories;
|
||||
}
|
||||
|
||||
const sanitizedQuery = sanitizeQuery(searchQuery);
|
||||
return repositories.filter((repository) => {
|
||||
const sanitizedRepoName = sanitizeQuery(repository.full_name);
|
||||
return sanitizedRepoName.includes(sanitizedQuery);
|
||||
});
|
||||
}, [repositories, searchQuery]);
|
||||
|
||||
if (numberOfRepoMicroagents === 0) {
|
||||
if (tabType === "personal") {
|
||||
return (
|
||||
<MicroagentManagementNoRepositories
|
||||
title={t(
|
||||
I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS,
|
||||
)}
|
||||
documentationUrl={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tabType === "repositories") {
|
||||
return (
|
||||
<MicroagentManagementNoRepositories
|
||||
title={t(I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS)}
|
||||
documentationUrl={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tabType === "organizations") {
|
||||
return (
|
||||
<MicroagentManagementNoRepositories
|
||||
title={t(
|
||||
I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS,
|
||||
)}
|
||||
documentationUrl={
|
||||
DOCUMENTATION_URL.MICROAGENTS.ORGANIZATION_AND_USER_MICROAGENTS
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{/* Search Input */}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<label htmlFor="repository-search" className="sr-only">
|
||||
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
|
||||
</label>
|
||||
<input
|
||||
id="repository-search"
|
||||
name="repository-search"
|
||||
type="text"
|
||||
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Repositories Accordion */}
|
||||
<Accordion
|
||||
variant="splitted"
|
||||
className="w-full px-0 gap-3"
|
||||
itemClasses={{
|
||||
base: "shadow-none bg-transparent border border-[#ffffff40] rounded-[6px] cursor-pointer",
|
||||
trigger: "cursor-pointer",
|
||||
}}
|
||||
selectionMode="multiple"
|
||||
>
|
||||
{filteredRepositories.map((repository) => (
|
||||
<AccordionItem
|
||||
key={repository.id}
|
||||
aria-label={repository.full_name}
|
||||
title={
|
||||
<MicroagentManagementAccordionTitle repository={repository} />
|
||||
}
|
||||
>
|
||||
<MicroagentManagementRepoMicroagents
|
||||
repoMicroagent={{
|
||||
id: repository.id,
|
||||
repositoryName: repository.full_name,
|
||||
repositoryUrl: `${getGitProviderBaseUrl(repository.git_provider)}/${repository.full_name}`,
|
||||
}}
|
||||
/>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Tab, Tabs } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MicroagentManagementMicroagents } from "./microagent-management-microagents";
|
||||
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
|
||||
import { useSelector } from "react-redux";
|
||||
import { MicroagentManagementRepositories } from "./microagent-management-repositories";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
export function MicroagentManagementSidebarTabs() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { repositories, personalRepositories, organizationRepositories } =
|
||||
useSelector((state: RootState) => state.microagentManagement);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
<Tabs
|
||||
@@ -17,18 +21,27 @@ export function MicroagentManagementSidebarTabs() {
|
||||
"w-full bg-transparent border border-[#ffffff40] rounded-[6px]",
|
||||
tab: "px-2 h-[22px]",
|
||||
tabContent: "text-white text-[12px] font-normal",
|
||||
panel: "py-0",
|
||||
panel: "p-0",
|
||||
cursor: "bg-[#C9B97480] rounded-sm",
|
||||
}}
|
||||
>
|
||||
<Tab key="personal" title={t(I18nKey.COMMON$PERSONAL)}>
|
||||
<MicroagentManagementMicroagents />
|
||||
<MicroagentManagementRepositories
|
||||
repositories={personalRepositories}
|
||||
tabType="personal"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key="repositories" title={t(I18nKey.COMMON$REPOSITORIES)}>
|
||||
<MicroagentManagementRepoMicroagents />
|
||||
<MicroagentManagementRepositories
|
||||
repositories={repositories}
|
||||
tabType="repositories"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key="organizations" title={t(I18nKey.COMMON$ORGANIZATIONS)}>
|
||||
<MicroagentManagementMicroagents />
|
||||
<MicroagentManagementRepositories
|
||||
repositories={organizationRepositories}
|
||||
tabType="organizations"
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,59 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
|
||||
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import {
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
setRepositories,
|
||||
} from "#/state/microagent-management-slice";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
|
||||
export function MicroagentManagementSidebar() {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { data: repositories, isLoading } = useUserRepositories();
|
||||
|
||||
useEffect(() => {
|
||||
if (repositories) {
|
||||
const personalRepos: GitRepository[] = [];
|
||||
const organizationRepos: GitRepository[] = [];
|
||||
const otherRepos: GitRepository[] = [];
|
||||
|
||||
repositories.forEach((repo: GitRepository) => {
|
||||
const hasOpenHandsSuffix = repo.full_name.endsWith("/.openhands");
|
||||
|
||||
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
|
||||
personalRepos.push(repo);
|
||||
} else if (repo.owner_type === "organization" && hasOpenHandsSuffix) {
|
||||
organizationRepos.push(repo);
|
||||
} else {
|
||||
otherRepos.push(repo);
|
||||
}
|
||||
});
|
||||
|
||||
dispatch(setPersonalRepositories(personalRepos));
|
||||
dispatch(setOrganizationRepositories(organizationRepos));
|
||||
dispatch(setRepositories(otherRepos));
|
||||
}
|
||||
}, [repositories, dispatch]);
|
||||
|
||||
return (
|
||||
<div className="w-[418px] h-full border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6">
|
||||
<div className="w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col">
|
||||
<MicroagentManagementSidebarHeader />
|
||||
<MicroagentManagementSidebarTabs />
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 flex-1">
|
||||
<LoadingSpinner size="small" />
|
||||
<span className="text-sm text-white">
|
||||
{t("HOME$LOADING_REPOSITORIES")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<MicroagentManagementSidebarTabs />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
interface SettingsButtonProps {
|
||||
onClick?: () => void;
|
||||
@@ -13,6 +14,12 @@ export function SettingsButton({
|
||||
disabled = false,
|
||||
}: SettingsButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
// Determine the correct settings path based on app mode
|
||||
// In SaaS mode, navigate directly to user settings to avoid the LLM settings page
|
||||
const settingsPath =
|
||||
config?.APP_MODE === "saas" ? "/settings/user" : "/settings";
|
||||
|
||||
return (
|
||||
<TooltipButton
|
||||
@@ -20,7 +27,7 @@ export function SettingsButton({
|
||||
tooltip={t(I18nKey.SETTINGS$TITLE)}
|
||||
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
|
||||
onClick={onClick}
|
||||
navLinkTo="/settings"
|
||||
navLinkTo={settingsPath}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SettingsIcon width={28} height={28} />
|
||||
|
||||
16
frontend/src/components/shared/git-provider-icon.tsx
Normal file
16
frontend/src/components/shared/git-provider-icon.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
interface GitProviderIconProps {
|
||||
gitProvider: Provider;
|
||||
}
|
||||
|
||||
export function GitProviderIcon({ gitProvider }: GitProviderIconProps) {
|
||||
return (
|
||||
<>
|
||||
{gitProvider === "github" && <FaGithub size={14} />}
|
||||
{gitProvider === "gitlab" && <FaGitlab />}
|
||||
{gitProvider === "bitbucket" && <FaBitbucket />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +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";
|
||||
|
||||
export const useAppInstallations = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["installations", providers, config?.GITHUB_CLIENT_ID],
|
||||
queryFn: OpenHands.getGitHubUserInstallationIds,
|
||||
enabled:
|
||||
userIsAuthenticated &&
|
||||
providers.includes("github") &&
|
||||
!!config?.GITHUB_CLIENT_ID &&
|
||||
config?.APP_MODE === "saas",
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@@ -1,22 +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";
|
||||
|
||||
export const useBitbucketWorkspaces = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["workspaces", providers],
|
||||
queryFn: OpenHands.getBitBucketWorkspaces,
|
||||
enabled:
|
||||
userIsAuthenticated &&
|
||||
providers.includes("bitbucket") &&
|
||||
config?.APP_MODE === "saas",
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@@ -15,7 +15,7 @@ export const useGetGitChanges = () => {
|
||||
queryKey: ["file_changes", conversationId],
|
||||
queryFn: () => OpenHands.getGitChanges(conversationId),
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
staleTime: 1000 * 30, // 30 seconds (reduced from 5 minutes to ensure fresher data after git operations)
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
enabled: runtimeIsReady && !!conversationId,
|
||||
meta: {
|
||||
|
||||
11
frontend/src/hooks/query/use-repository-microagents.ts
Normal file
11
frontend/src/hooks/query/use-repository-microagents.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useRepositoryMicroagents = (owner: string, repo: string) =>
|
||||
useQuery({
|
||||
queryKey: ["repository", "microagents", owner, repo],
|
||||
queryFn: () => OpenHands.getRepositoryMicroagents(owner, repo),
|
||||
enabled: !!owner && !!repo,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
@@ -708,4 +708,8 @@ export enum I18nKey {
|
||||
COMMON$RUN_TEST = "COMMON$RUN_TEST",
|
||||
COMMON$RUN_APP = "COMMON$RUN_APP",
|
||||
COMMON$LEARN_FILE_STRUCTURE = "COMMON$LEARN_FILE_STRUCTURE",
|
||||
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS",
|
||||
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS",
|
||||
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS",
|
||||
COMMON$SEARCH_REPOSITORIES = "COMMON$SEARCH_REPOSITORIES",
|
||||
}
|
||||
|
||||
@@ -11197,7 +11197,7 @@
|
||||
"fr": "Que souhaitez-vous que le microagent fasse ?",
|
||||
"tr": "Mikro ajanın ne yapmasını istersiniz?",
|
||||
"de": "Was soll der Microagent tun?",
|
||||
"uk": "Що ви хочете, щоб зробив мікроагент?"
|
||||
"uk": "Що в,и хочете, щоб зробив мікроагент?"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO": {
|
||||
"en": "Describe what you would like the Microagent to do.",
|
||||
@@ -11326,5 +11326,69 @@
|
||||
"tr": "Dosya yapısını öğren",
|
||||
"de": "Dateistruktur lernen",
|
||||
"uk": "Вивчити структуру файлів"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS": {
|
||||
"en": "You do not have user-level microagents",
|
||||
"ja": "ユーザーレベルのマイクロエージェントがありません",
|
||||
"zh-CN": "您没有用户级微代理",
|
||||
"zh-TW": "您沒有使用者層級的微代理",
|
||||
"ko-KR": "사용자 수준의 마이크로에이전트가 없습니다",
|
||||
"no": "Du har ikke mikroagenter på brukernivå",
|
||||
"it": "Non hai microagenti a livello utente",
|
||||
"pt": "Você não possui microagentes de nível de usuário",
|
||||
"es": "No tienes microagentes a nivel de usuario",
|
||||
"ar": "ليس لديك وكلاء دقيقون على مستوى المستخدم",
|
||||
"fr": "Vous n'avez pas de microagents au niveau utilisateur",
|
||||
"tr": "Kullanıcı düzeyinde mikro ajanınız yok",
|
||||
"de": "Sie haben keine Mikroagenten auf Benutzerebene",
|
||||
"uk": "У вас немає мікроагентів на рівні користувача"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS": {
|
||||
"en": "You do not have microagents",
|
||||
"ja": "マイクロエージェントがありません",
|
||||
"zh-CN": "您没有微代理",
|
||||
"zh-TW": "您沒有微代理",
|
||||
"ko-KR": "마이크로에이전트가 없습니다",
|
||||
"no": "Du har ingen mikroagenter",
|
||||
"it": "Non hai microagenti",
|
||||
"pt": "Você não possui microagentes",
|
||||
"es": "No tienes microagentes",
|
||||
"ar": "ليس لديك وكلاء دقيقون",
|
||||
"fr": "Vous n'avez pas de microagents",
|
||||
"tr": "Mikro ajanınız yok",
|
||||
"de": "Sie haben keine Mikroagenten",
|
||||
"uk": "У вас немає мікроагентів"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS": {
|
||||
"en": "You do not have organization-level microagents",
|
||||
"ja": "組織レベルのマイクロエージェントがありません",
|
||||
"zh-CN": "您没有组织级微代理",
|
||||
"zh-TW": "您沒有組織層級的微代理",
|
||||
"ko-KR": "조직 수준의 마이크로에이전트가 없습니다",
|
||||
"no": "Du har ikke mikroagenter på organisasjonsnivå",
|
||||
"it": "Non hai microagenti a livello organizzazione",
|
||||
"pt": "Você não possui microagentes de nível organizacional",
|
||||
"es": "No tienes microagentes a nivel de organización",
|
||||
"ar": "ليس لديك وكلاء دقيقون على مستوى المؤسسة",
|
||||
"fr": "Vous n'avez pas de microagents au niveau organisation",
|
||||
"tr": "Organizasyon düzeyinde mikro ajanınız yok",
|
||||
"de": "Sie haben keine Mikroagenten auf Organisationsebene",
|
||||
"uk": "У вас немає мікроагентів на рівні організації"
|
||||
},
|
||||
"COMMON$SEARCH_REPOSITORIES": {
|
||||
"en": "Search repositories",
|
||||
"ja": "リポジトリを検索",
|
||||
"zh-CN": "搜索仓库",
|
||||
"zh-TW": "搜尋存儲庫",
|
||||
"ko-KR": "저장소 검색",
|
||||
"no": "Søk i repositories",
|
||||
"it": "Cerca repository",
|
||||
"pt": "Pesquisar repositórios",
|
||||
"es": "Buscar repositorios",
|
||||
"ar": "البحث في المستودعات",
|
||||
"fr": "Rechercher des dépôts",
|
||||
"tr": "Depo ara",
|
||||
"de": "Repositorys durchsuchen",
|
||||
"uk": "Пошук репозиторіїв"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import { GitRepository } from "#/types/git";
|
||||
|
||||
export const microagentManagementSlice = createSlice({
|
||||
name: "microagentManagement",
|
||||
@@ -6,6 +7,9 @@ export const microagentManagementSlice = createSlice({
|
||||
selectedMicroagent: null,
|
||||
addMicroagentModalVisible: false,
|
||||
selectedRepository: null,
|
||||
personalRepositories: [] as GitRepository[],
|
||||
organizationRepositories: [] as GitRepository[],
|
||||
repositories: [] as GitRepository[],
|
||||
},
|
||||
reducers: {
|
||||
setSelectedMicroagent: (state, action) => {
|
||||
@@ -17,6 +21,15 @@ export const microagentManagementSlice = createSlice({
|
||||
setSelectedRepository: (state, action) => {
|
||||
state.selectedRepository = action.payload;
|
||||
},
|
||||
setPersonalRepositories: (state, action) => {
|
||||
state.personalRepositories = action.payload;
|
||||
},
|
||||
setOrganizationRepositories: (state, action) => {
|
||||
state.organizationRepositories = action.payload;
|
||||
},
|
||||
setRepositories: (state, action) => {
|
||||
state.repositories = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +37,9 @@ export const {
|
||||
setSelectedMicroagent,
|
||||
setAddMicroagentModalVisible,
|
||||
setSelectedRepository,
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
setRepositories,
|
||||
} = microagentManagementSlice.actions;
|
||||
|
||||
export default microagentManagementSlice.reducer;
|
||||
|
||||
1
frontend/src/types/git.d.ts
vendored
1
frontend/src/types/git.d.ts
vendored
@@ -30,6 +30,7 @@ interface GitRepository {
|
||||
stargazers_count?: number;
|
||||
link_header?: string;
|
||||
pushed_at?: string;
|
||||
owner_type?: "user" | "organization";
|
||||
}
|
||||
|
||||
interface GitHubCommit {
|
||||
|
||||
12
frontend/src/types/microagent-management.tsx
Normal file
12
frontend/src/types/microagent-management.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export type TabType = "personal" | "repositories" | "organizations";
|
||||
|
||||
export interface RepositoryMicroagent {
|
||||
name: string;
|
||||
type: "repo" | "knowledge";
|
||||
content: string;
|
||||
triggers: string[];
|
||||
inputs: string[];
|
||||
tools: string[];
|
||||
created_at: string;
|
||||
git_provider: string;
|
||||
}
|
||||
@@ -28,3 +28,12 @@ export const JSON_VIEW_THEME = {
|
||||
base0E: "#c792ea", // keywords, purple
|
||||
base0F: "#ff5370", // deprecated, red
|
||||
};
|
||||
|
||||
export const DOCUMENTATION_URL = {
|
||||
MICROAGENTS: {
|
||||
MICROAGENTS_OVERVIEW:
|
||||
"https://docs.all-hands.dev/usage/prompting/microagents-overview",
|
||||
ORGANIZATION_AND_USER_MICROAGENTS:
|
||||
"https://docs.all-hands.dev/usage/prompting/microagents-org",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,3 +26,19 @@ export const formatTimeDelta = (date: Date) => {
|
||||
if (months < 12) return `${months}mo`;
|
||||
return `${years}y`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a date into a MM/DD/YYYY string format.
|
||||
* @param date The date to format
|
||||
* @returns A string in MM/DD/YYYY format
|
||||
*
|
||||
* @example
|
||||
* formatDateMMDDYYYY(new Date("2025-05-30T00:15:08")); // "05/30/2025"
|
||||
* formatDateMMDDYYYY(new Date("2024-12-25T10:30:00")); // "12/25/2024"
|
||||
*/
|
||||
export const formatDateMMDDYYYY = (date: Date) =>
|
||||
date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -102,3 +103,16 @@ export const formatTimestamp = (timestamp: string) =>
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
export const getGitProviderBaseUrl = (gitProvider: Provider): string => {
|
||||
switch (gitProvider) {
|
||||
case "github":
|
||||
return "https://github.com";
|
||||
case "gitlab":
|
||||
return "https://gitlab.com";
|
||||
case "bitbucket":
|
||||
return "https://bitbucket.org";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,7 +17,9 @@ from openhands.cli.settings import modify_llm_settings_basic
|
||||
from openhands.cli.shell_config import (
|
||||
ShellConfigManager,
|
||||
add_aliases_to_shell_config,
|
||||
alias_setup_declined,
|
||||
aliases_exist_in_shell_config,
|
||||
mark_alias_setup_declined,
|
||||
)
|
||||
from openhands.cli.tui import (
|
||||
UsageMetrics,
|
||||
@@ -387,106 +389,86 @@ def run_alias_setup_flow(config: OpenHandsConfig) -> None:
|
||||
|
||||
Prompts the user to set up aliases for 'openhands' and 'oh' commands.
|
||||
Handles existing aliases by offering to keep or remove them.
|
||||
|
||||
Args:
|
||||
config: OpenHands configuration
|
||||
"""
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML('<gold>🚀 Welcome to OpenHands CLI!</gold>'))
|
||||
print_formatted_text('')
|
||||
|
||||
# Check if aliases already exist
|
||||
if aliases_exist_in_shell_config():
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey>We detected existing OpenHands aliases in your shell configuration.</grey>'
|
||||
)
|
||||
# Show the normal setup flow
|
||||
print_formatted_text(
|
||||
HTML('<grey>Would you like to set up convenient shell aliases?</grey>')
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<grey>This will add the following aliases to your shell profile:</grey>')
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<ansiyellow>⚠️ Note: This requires uv to be installed first.</ansiyellow>'
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<ansigreen>✅ Aliases are already configured.</ansigreen>')
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<ansiyellow> Installation guide: https://docs.astral.sh/uv/getting-started/installation</ansiyellow>'
|
||||
)
|
||||
return # Exit early since aliases already exist
|
||||
else:
|
||||
# No existing aliases, show the normal setup flow
|
||||
print_formatted_text(
|
||||
HTML('<grey>Would you like to set up convenient shell aliases?</grey>')
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey>This will add the following aliases to your shell profile:</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<ansiyellow>⚠️ Note: This requires uv to be installed first.</ansiyellow>'
|
||||
)
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<ansiyellow> Installation guide: https://docs.astral.sh/uv/getting-started/installation</ansiyellow>'
|
||||
)
|
||||
)
|
||||
print_formatted_text('')
|
||||
)
|
||||
print_formatted_text('')
|
||||
|
||||
# Use cli_confirm to get user choice
|
||||
choice = cli_confirm(
|
||||
config,
|
||||
'Set up shell aliases?',
|
||||
['Yes, set up aliases', 'No, skip this step'],
|
||||
)
|
||||
# Use cli_confirm to get user choice
|
||||
choice = cli_confirm(
|
||||
config,
|
||||
'Set up shell aliases?',
|
||||
['Yes, set up aliases', 'No, skip this step'],
|
||||
)
|
||||
|
||||
if choice == 0: # User chose "Yes"
|
||||
success = add_aliases_to_shell_config()
|
||||
if success:
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<ansigreen>✅ Aliases added successfully!</ansigreen>')
|
||||
if choice == 0: # User chose "Yes"
|
||||
success = add_aliases_to_shell_config()
|
||||
if success:
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<ansigreen>✅ Aliases added successfully!</ansigreen>')
|
||||
)
|
||||
|
||||
# Get the appropriate reload command using the shell config manager
|
||||
shell_manager = ShellConfigManager()
|
||||
reload_cmd = shell_manager.get_reload_command()
|
||||
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f'<grey>Run <b>{reload_cmd}</b> (or restart your terminal) to use the new aliases.</grey>'
|
||||
)
|
||||
|
||||
# Get the appropriate reload command using the shell config manager
|
||||
shell_manager = ShellConfigManager()
|
||||
reload_cmd = shell_manager.get_reload_command()
|
||||
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f'<grey>Run <b>{reload_cmd}</b> (or restart your terminal) to use the new aliases.</grey>'
|
||||
)
|
||||
)
|
||||
else:
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<ansired>❌ Failed to add aliases. You can set them up manually later.</ansired>'
|
||||
)
|
||||
)
|
||||
else: # User chose "No"
|
||||
)
|
||||
else:
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey>Skipped alias setup. You can run this setup again anytime.</grey>'
|
||||
'<ansired>❌ Failed to add aliases. You can set them up manually later.</ansired>'
|
||||
)
|
||||
)
|
||||
else: # User chose "No"
|
||||
# Mark that the user has declined alias setup
|
||||
mark_alias_setup_declined()
|
||||
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey>Skipped alias setup. You can run this setup again anytime.</grey>'
|
||||
)
|
||||
)
|
||||
|
||||
print_formatted_text('')
|
||||
|
||||
@@ -583,15 +565,23 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
finalize_config(config)
|
||||
|
||||
# Check if we should show the alias setup flow
|
||||
# Only show it if aliases don't exist in the shell configuration
|
||||
# and we're in an interactive environment (not during tests or CI)
|
||||
if not aliases_exist_in_shell_config() and sys.stdin.isatty():
|
||||
# Clear the terminal if we haven't shown a banner yet
|
||||
# Only show it if:
|
||||
# 1. Aliases don't exist in the shell configuration
|
||||
# 2. User hasn't previously declined alias setup
|
||||
# 3. We're in an interactive environment (not during tests or CI)
|
||||
should_show_alias_setup = (
|
||||
not aliases_exist_in_shell_config()
|
||||
and not alias_setup_declined()
|
||||
and sys.stdin.isatty()
|
||||
)
|
||||
|
||||
if should_show_alias_setup:
|
||||
# Clear the terminal if we haven't shown a banner yet (i.e., setup flow didn't run)
|
||||
if not banner_shown:
|
||||
clear()
|
||||
|
||||
run_alias_setup_flow(config)
|
||||
banner_shown = True
|
||||
# Don't set banner_shown = True here, so the ASCII art banner will still be shown
|
||||
|
||||
# TODO: Set working directory from config or use current working directory?
|
||||
current_dir = config.workspace_base
|
||||
|
||||
@@ -277,3 +277,21 @@ def get_shell_config_path() -> Path:
|
||||
"""Get the path to the shell configuration file."""
|
||||
manager = ShellConfigManager()
|
||||
return manager.get_shell_config_path()
|
||||
|
||||
|
||||
def alias_setup_declined() -> bool:
|
||||
"""Check if the user has previously declined alias setup.
|
||||
|
||||
Returns:
|
||||
True if user has declined alias setup, False otherwise.
|
||||
"""
|
||||
marker_file = Path.home() / '.openhands' / '.cli_alias_setup_declined'
|
||||
return marker_file.exists()
|
||||
|
||||
|
||||
def mark_alias_setup_declined() -> None:
|
||||
"""Mark that the user has declined alias setup."""
|
||||
openhands_dir = Path.home() / '.openhands'
|
||||
openhands_dir.mkdir(exist_ok=True)
|
||||
marker_file = openhands_dir / '.cli_alias_setup_declined'
|
||||
marker_file.touch()
|
||||
|
||||
@@ -42,6 +42,13 @@ def suppress_cli_warnings():
|
||||
category=UserWarning,
|
||||
)
|
||||
|
||||
# Suppress LiteLLM close_litellm_async_clients was never awaited warning
|
||||
warnings.filterwarnings(
|
||||
'ignore',
|
||||
message="coroutine 'close_litellm_async_clients' was never awaited",
|
||||
category=RuntimeWarning,
|
||||
)
|
||||
|
||||
|
||||
# Apply warning suppressions when module is imported
|
||||
suppress_cli_warnings()
|
||||
|
||||
@@ -9,7 +9,6 @@ from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
ProviderType,
|
||||
Repository,
|
||||
@@ -21,7 +20,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
|
||||
@@ -186,89 +185,7 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
|
||||
return all_items[:max_items] # Trim to max_items if needed
|
||||
|
||||
async def get_installations(self) -> list[str]:
|
||||
workspaces_url = f'{self.BASE_URL}/workspaces'
|
||||
workspaces = await self._fetch_paginated_data(workspaces_url, {}, 100)
|
||||
|
||||
installations: list[str] = []
|
||||
for workspace in workspaces:
|
||||
installations.append(workspace['slug'])
|
||||
|
||||
return installations
|
||||
|
||||
async def get_paginated_repos(
|
||||
self, page: int, per_page: int, sort: str, installation_id: str | None
|
||||
) -> list[Repository]:
|
||||
"""Get paginated repositories for a specific workspace.
|
||||
|
||||
Args:
|
||||
page: The page number to fetch
|
||||
per_page: The number of repositories per page
|
||||
sort: The sort field ('pushed', 'updated', 'created', 'full_name')
|
||||
installation_id: The workspace slug to fetch repositories from (as int, will be converted to string)
|
||||
|
||||
Returns:
|
||||
A list of Repository objects
|
||||
"""
|
||||
if not installation_id:
|
||||
return []
|
||||
|
||||
# Convert installation_id to string for use as workspace_slug
|
||||
workspace_slug = installation_id
|
||||
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
|
||||
|
||||
# Map sort parameter to Bitbucket API compatible values
|
||||
bitbucket_sort = sort
|
||||
if sort == 'pushed':
|
||||
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
|
||||
bitbucket_sort = '-updated_on' # Use negative prefix for descending order
|
||||
elif sort == 'updated':
|
||||
bitbucket_sort = '-updated_on'
|
||||
elif sort == 'created':
|
||||
bitbucket_sort = '-created_on'
|
||||
elif sort == 'full_name':
|
||||
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
|
||||
else:
|
||||
# Default to most recently updated first
|
||||
bitbucket_sort = '-updated_on'
|
||||
|
||||
params = {
|
||||
'pagelen': per_page,
|
||||
'page': page,
|
||||
'sort': bitbucket_sort,
|
||||
}
|
||||
|
||||
response, headers = await self._make_request(workspace_repos_url, params)
|
||||
|
||||
# Extract repositories from the response
|
||||
repos = response.get('values', [])
|
||||
|
||||
# Extract link header for pagination
|
||||
next_link = response.get('next', '')
|
||||
|
||||
repositories = [
|
||||
Repository(
|
||||
id=repo.get('uuid', ''),
|
||||
full_name=f'{repo.get("workspace", {}).get("slug", "")}/{repo.get("slug", "")}',
|
||||
git_provider=ProviderType.BITBUCKET,
|
||||
is_public=repo.get('is_private', True) is False,
|
||||
stargazers_count=None, # Bitbucket doesn't have stars
|
||||
pushed_at=repo.get('updated_on'),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('workspace', {}).get('is_private') is False
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=next_link,
|
||||
)
|
||||
for repo in repos
|
||||
]
|
||||
|
||||
return repositories
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
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
|
||||
|
||||
@@ -15,7 +15,6 @@ from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
ProviderType,
|
||||
Repository,
|
||||
@@ -29,7 +28,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?
|
||||
@@ -193,47 +192,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
|
||||
|
||||
async def get_paginated_repos(
|
||||
self, page: int, per_page: int, sort: str, installation_id: str | None
|
||||
):
|
||||
params = {'page': str(page), 'per_page': str(per_page)}
|
||||
if installation_id:
|
||||
url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
|
||||
response, headers = await self._make_request(url, params)
|
||||
response = response.get('repositories', [])
|
||||
else:
|
||||
url = f'{self.BASE_URL}/user/repos'
|
||||
params['sort'] = sort
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
next_link: str = headers.get('Link', '')
|
||||
return [
|
||||
Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('full_name'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=not repo.get('private', True),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('owner', {}).get('type') == 'Organization'
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=next_link,
|
||||
)
|
||||
for repo in response
|
||||
]
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
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:
|
||||
@@ -280,11 +246,11 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
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
|
||||
|
||||
@@ -226,49 +226,7 @@ class GitLabService(BaseGitService, GitService):
|
||||
|
||||
return repos
|
||||
|
||||
async def get_paginated_repos(
|
||||
self, page: int, per_page: int, sort: str, installation_id: str | None
|
||||
) -> list[Repository]:
|
||||
url = f'{self.BASE_URL}/projects'
|
||||
order_by = {
|
||||
'pushed': 'last_activity_at',
|
||||
'updated': 'last_activity_at',
|
||||
'created': 'created_at',
|
||||
'full_name': 'name',
|
||||
}.get(sort, 'last_activity_at')
|
||||
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'order_by': order_by,
|
||||
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
|
||||
'owned': True, # Boolean value without quotes
|
||||
'membership': True, # Include projects user is a member of
|
||||
}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
next_link: str = headers.get('Link', '')
|
||||
repos = [
|
||||
Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=repo.get('visibility') == 'public',
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('namespace', {}).get('kind') == 'group'
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=next_link,
|
||||
)
|
||||
for repo in response
|
||||
]
|
||||
return repos
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
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] = []
|
||||
|
||||
@@ -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,
|
||||
ProviderType,
|
||||
Repository,
|
||||
SuggestedTask,
|
||||
@@ -161,61 +160,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}')
|
||||
|
||||
@@ -200,12 +200,6 @@ class BaseGitService(ABC):
|
||||
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
|
||||
|
||||
|
||||
class InstallationsService(Protocol):
|
||||
async def get_installations(self) -> list[str]:
|
||||
"""Get installations for the service; repos live underneath these installations"""
|
||||
...
|
||||
|
||||
|
||||
class GitService(Protocol):
|
||||
"""Protocol defining the interface for Git service providers"""
|
||||
|
||||
@@ -239,18 +233,10 @@ class GitService(Protocol):
|
||||
"""Search for 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"""
|
||||
...
|
||||
|
||||
async def get_paginated_repos(
|
||||
self, page: int, per_page: int, sort: str, installation_id: str | None
|
||||
) -> list[Repository]:
|
||||
"""Get a page of repositories for the authenticated user"""
|
||||
...
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories"""
|
||||
...
|
||||
|
||||
@@ -210,6 +210,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
extra_deps=self.config.sandbox.runtime_extra_deps,
|
||||
force_rebuild=self.config.sandbox.force_rebuild_runtime,
|
||||
extra_build_args=self.config.sandbox.runtime_extra_build_args,
|
||||
enable_browser=self.config.enable_browser,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -623,8 +623,16 @@ def _create_server(
|
||||
os.getenv('VSCODE_PORT') or str(find_available_tcp_port(*VSCODE_PORT_RANGE))
|
||||
)
|
||||
app_ports = [
|
||||
int(os.getenv('APP_PORT_1') or str(find_available_tcp_port(*APP_PORT_RANGE_1))),
|
||||
int(os.getenv('APP_PORT_2') or str(find_available_tcp_port(*APP_PORT_RANGE_2))),
|
||||
int(
|
||||
os.getenv('WORK_PORT_1')
|
||||
or os.getenv('APP_PORT_1')
|
||||
or str(find_available_tcp_port(*APP_PORT_RANGE_1))
|
||||
),
|
||||
int(
|
||||
os.getenv('WORK_PORT_2')
|
||||
or os.getenv('APP_PORT_2')
|
||||
or str(find_available_tcp_port(*APP_PORT_RANGE_2))
|
||||
),
|
||||
]
|
||||
|
||||
# Get user info
|
||||
|
||||
@@ -250,6 +250,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
platform=self.config.sandbox.platform,
|
||||
extra_deps=self.config.sandbox.runtime_extra_deps,
|
||||
force_rebuild=self.config.sandbox.force_rebuild_runtime,
|
||||
enable_browser=self.config.enable_browser,
|
||||
)
|
||||
|
||||
response = self._send_runtime_api_request(
|
||||
|
||||
@@ -79,6 +79,9 @@ class GitHandler:
|
||||
"""
|
||||
Determines a valid Git reference for comparison.
|
||||
|
||||
This method intelligently selects a comparison base that avoids showing
|
||||
merged changes as if they were user-created changes.
|
||||
|
||||
Returns:
|
||||
str | None: A valid Git reference or None if no valid reference is found.
|
||||
"""
|
||||
@@ -90,8 +93,23 @@ class GitHandler:
|
||||
ref_default_branch = 'origin/' + default_branch
|
||||
ref_new_repo = '$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
|
||||
|
||||
# Check if the current branch's remote tracking branch exists
|
||||
if self._verify_ref_exists(ref_current_branch):
|
||||
# Check if the current HEAD has diverged significantly from the remote tracking branch
|
||||
# This happens after merging changes from the default branch
|
||||
if self._has_diverged_from_remote_tracking_branch(
|
||||
current_branch, default_branch
|
||||
):
|
||||
# If we've diverged due to a merge, prefer using merge-base with default branch
|
||||
# This prevents showing merged changes as user changes
|
||||
if self._verify_ref_exists(ref_non_default_branch):
|
||||
return ref_non_default_branch
|
||||
|
||||
# If no significant divergence or merge-base doesn't exist, use the remote tracking branch
|
||||
return ref_current_branch
|
||||
|
||||
# Fallback to other references if remote tracking branch doesn't exist
|
||||
refs = [
|
||||
ref_current_branch,
|
||||
ref_non_default_branch,
|
||||
ref_default_branch,
|
||||
ref_new_repo,
|
||||
@@ -102,6 +120,73 @@ class GitHandler:
|
||||
|
||||
return None
|
||||
|
||||
def _has_diverged_from_remote_tracking_branch(
|
||||
self, current_branch: str, default_branch: str
|
||||
) -> bool:
|
||||
"""
|
||||
Checks if the current branch has diverged significantly from its remote tracking branch.
|
||||
|
||||
This typically happens after merging changes from the default branch, where the local
|
||||
branch has new commits but the remote tracking branch hasn't been updated yet.
|
||||
|
||||
Args:
|
||||
current_branch (str): The name of the current branch.
|
||||
default_branch (str): The name of the default branch.
|
||||
|
||||
Returns:
|
||||
bool: True if the branch has diverged due to a merge, False otherwise.
|
||||
"""
|
||||
try:
|
||||
# Get the commit hash of the current HEAD
|
||||
head_cmd = 'git --no-pager rev-parse HEAD'
|
||||
head_result = self.execute(head_cmd, self.cwd)
|
||||
if head_result.exit_code != 0:
|
||||
return False
|
||||
current_head = head_result.content.strip()
|
||||
|
||||
# Get the commit hash of the remote tracking branch
|
||||
remote_branch_cmd = f'git --no-pager rev-parse origin/{current_branch}'
|
||||
remote_result = self.execute(remote_branch_cmd, self.cwd)
|
||||
if remote_result.exit_code != 0:
|
||||
return False
|
||||
remote_head = remote_result.content.strip()
|
||||
|
||||
# If they're the same, no divergence
|
||||
if current_head == remote_head:
|
||||
return False
|
||||
|
||||
# Check if the current HEAD is ahead of the remote tracking branch
|
||||
ahead_cmd = f'git --no-pager rev-list --count origin/{current_branch}..HEAD'
|
||||
ahead_result = self.execute(ahead_cmd, self.cwd)
|
||||
if ahead_result.exit_code != 0:
|
||||
return False
|
||||
|
||||
commits_ahead = int(ahead_result.content.strip())
|
||||
|
||||
# Check if the current HEAD contains commits from the default branch
|
||||
# that are not in the remote tracking branch
|
||||
if commits_ahead > 0:
|
||||
# Check if any of the commits ahead are merge commits or contain changes from default branch
|
||||
merge_check_cmd = f'git --no-pager log --oneline --merges origin/{current_branch}..HEAD'
|
||||
merge_result = self.execute(merge_check_cmd, self.cwd)
|
||||
|
||||
# If there are merge commits, this indicates a divergence due to merging
|
||||
if merge_result.exit_code == 0 and merge_result.content.strip():
|
||||
return True
|
||||
|
||||
# Also check if the commits ahead include changes that exist in the default branch
|
||||
# This catches cases where changes were merged without creating a merge commit
|
||||
if (
|
||||
commits_ahead >= 2
|
||||
): # Threshold to avoid false positives for single commits
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except (ValueError, Exception):
|
||||
# If any error occurs, assume no divergence to be safe
|
||||
return False
|
||||
|
||||
def _get_ref_content(self, file_path: str) -> str:
|
||||
"""
|
||||
Retrieves the content of a file from a valid Git reference.
|
||||
@@ -129,7 +214,14 @@ class GitHandler:
|
||||
"""
|
||||
cmd = 'git --no-pager remote show origin | grep "HEAD branch"'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.split()[-1].strip()
|
||||
if output.exit_code != 0 or not output.content.strip():
|
||||
# Fallback to 'main' if no remote origin exists
|
||||
return 'main'
|
||||
|
||||
parts = output.content.split()
|
||||
if len(parts) == 0:
|
||||
return 'main'
|
||||
return parts[-1].strip()
|
||||
|
||||
def _get_current_branch(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -32,6 +32,7 @@ def _generate_dockerfile(
|
||||
base_image: str,
|
||||
build_from: BuildFromImageType = BuildFromImageType.SCRATCH,
|
||||
extra_deps: str | None = None,
|
||||
enable_browser: bool = True,
|
||||
) -> str:
|
||||
"""Generate the Dockerfile content for the runtime image based on the base image.
|
||||
|
||||
@@ -39,6 +40,7 @@ def _generate_dockerfile(
|
||||
- base_image (str): The base image provided for the runtime image
|
||||
- build_from (BuildFromImageType): The build method for the runtime image.
|
||||
- extra_deps (str):
|
||||
- enable_browser (bool): Whether to enable browser support (install Playwright)
|
||||
|
||||
Returns:
|
||||
- str: The resulting Dockerfile content
|
||||
@@ -55,6 +57,7 @@ def _generate_dockerfile(
|
||||
build_from_scratch=build_from == BuildFromImageType.SCRATCH,
|
||||
build_from_versioned=build_from == BuildFromImageType.VERSIONED,
|
||||
extra_deps=extra_deps if extra_deps is not None else '',
|
||||
enable_browser=enable_browser,
|
||||
)
|
||||
return dockerfile_content
|
||||
|
||||
@@ -111,6 +114,7 @@ def build_runtime_image(
|
||||
dry_run: bool = False,
|
||||
force_rebuild: bool = False,
|
||||
extra_build_args: list[str] | None = None,
|
||||
enable_browser: bool = True,
|
||||
) -> str:
|
||||
"""Prepares the final docker build folder.
|
||||
|
||||
@@ -125,6 +129,7 @@ def build_runtime_image(
|
||||
- dry_run (bool): if True, it will only ready the build folder. It will not actually build the Docker image
|
||||
- force_rebuild (bool): if True, it will create the Dockerfile which uses the base_image
|
||||
- extra_build_args (List[str]): Additional build arguments to pass to the builder
|
||||
- enable_browser (bool): Whether to enable browser support (install Playwright)
|
||||
|
||||
Returns:
|
||||
- str: <image_repo>:<MD5 hash>. Where MD5 hash is the hash of the docker build folder
|
||||
@@ -142,6 +147,7 @@ def build_runtime_image(
|
||||
force_rebuild=force_rebuild,
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
enable_browser=enable_browser,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -154,6 +160,7 @@ def build_runtime_image(
|
||||
force_rebuild=force_rebuild,
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
enable_browser=enable_browser,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -167,9 +174,10 @@ def build_runtime_image_in_folder(
|
||||
force_rebuild: bool,
|
||||
platform: str | None = None,
|
||||
extra_build_args: list[str] | None = None,
|
||||
enable_browser: bool = True,
|
||||
) -> str:
|
||||
runtime_image_repo, _ = get_runtime_image_repo_and_tag(base_image)
|
||||
lock_tag = f'oh_v{oh_version}_{get_hash_for_lock_files(base_image)}'
|
||||
lock_tag = f'oh_v{oh_version}_{get_hash_for_lock_files(base_image, enable_browser)}'
|
||||
versioned_tag = (
|
||||
# truncate the base image to 96 characters to fit in the tag max length (128 characters)
|
||||
f'oh_v{oh_version}_{get_tag_for_versioned_image(base_image)}'
|
||||
@@ -188,6 +196,7 @@ def build_runtime_image_in_folder(
|
||||
base_image,
|
||||
build_from=BuildFromImageType.SCRATCH,
|
||||
extra_deps=extra_deps,
|
||||
enable_browser=enable_browser,
|
||||
)
|
||||
if not dry_run:
|
||||
_build_sandbox_image(
|
||||
@@ -226,7 +235,7 @@ def build_runtime_image_in_folder(
|
||||
else:
|
||||
logger.debug(f'Build [{hash_image_name}] from scratch')
|
||||
|
||||
prep_build_folder(build_folder, base_image, build_from, extra_deps)
|
||||
prep_build_folder(build_folder, base_image, build_from, extra_deps, enable_browser)
|
||||
if not dry_run:
|
||||
_build_sandbox_image(
|
||||
build_folder,
|
||||
@@ -251,6 +260,7 @@ def prep_build_folder(
|
||||
base_image: str,
|
||||
build_from: BuildFromImageType,
|
||||
extra_deps: str | None,
|
||||
enable_browser: bool = True,
|
||||
) -> None:
|
||||
# Copy the source code to directory. It will end up in build_folder/code
|
||||
# If package is not found, build from source code
|
||||
@@ -282,6 +292,7 @@ def prep_build_folder(
|
||||
base_image,
|
||||
build_from=build_from,
|
||||
extra_deps=extra_deps,
|
||||
enable_browser=enable_browser,
|
||||
)
|
||||
dockerfile_path = Path(build_folder, 'Dockerfile')
|
||||
with open(str(dockerfile_path), 'w') as f:
|
||||
@@ -301,10 +312,13 @@ def truncate_hash(hash: str) -> str:
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
def get_hash_for_lock_files(base_image: str) -> str:
|
||||
def get_hash_for_lock_files(base_image: str, enable_browser: bool = True) -> str:
|
||||
openhands_source_dir = Path(openhands.__file__).parent
|
||||
md5 = hashlib.md5()
|
||||
md5.update(base_image.encode())
|
||||
# Only include enable_browser in hash when it's False for backward compatibility
|
||||
if not enable_browser:
|
||||
md5.update(str(enable_browser).encode())
|
||||
for file in ['pyproject.toml', 'poetry.lock']:
|
||||
src = Path(openhands_source_dir, file)
|
||||
if not src.exists():
|
||||
@@ -378,6 +392,10 @@ if __name__ == '__main__':
|
||||
parser.add_argument('--build_folder', type=str, default=None)
|
||||
parser.add_argument('--force_rebuild', action='store_true', default=False)
|
||||
parser.add_argument('--platform', type=str, default=None)
|
||||
parser.add_argument('--enable_browser', action='store_true', default=True)
|
||||
parser.add_argument(
|
||||
'--no_enable_browser', dest='enable_browser', action='store_false'
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.build_folder is not None:
|
||||
@@ -409,6 +427,7 @@ if __name__ == '__main__':
|
||||
dry_run=True,
|
||||
force_rebuild=args.force_rebuild,
|
||||
platform=args.platform,
|
||||
enable_browser=args.enable_browser,
|
||||
)
|
||||
|
||||
_runtime_image_repo, runtime_image_source_tag = (
|
||||
@@ -444,6 +463,9 @@ if __name__ == '__main__':
|
||||
logger.debug('Building image in a temporary folder')
|
||||
docker_builder = DockerRuntimeBuilder(docker.from_env())
|
||||
image_name = build_runtime_image(
|
||||
args.base_image, docker_builder, platform=args.platform
|
||||
args.base_image,
|
||||
docker_builder,
|
||||
platform=args.platform,
|
||||
enable_browser=args.enable_browser,
|
||||
)
|
||||
logger.debug(f'\nBuilt image: {image_name}\n')
|
||||
|
||||
@@ -127,7 +127,9 @@ RUN \
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry install --only main,runtime --no-interaction --no-root && \
|
||||
# Update and install additional tools
|
||||
# (There used to be an "apt-get update" here, hopefully we can skip it.)
|
||||
{% if enable_browser %}
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install --with-deps chromium && \
|
||||
{% endif %}
|
||||
# Set environment variables
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry run python -c "import sys; print('OH_INTERPRETER_PATH=' + sys.executable)" >> /etc/environment && \
|
||||
# Set permissions
|
||||
|
||||
@@ -38,55 +38,9 @@ from openhands.server.user_auth import (
|
||||
app = APIRouter(prefix='/api/user', dependencies=get_dependencies())
|
||||
|
||||
|
||||
@app.get('/github/installations', response_model=list[str])
|
||||
async def get_user_github_installations(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
return await client.get_github_installations()
|
||||
|
||||
return JSONResponse(
|
||||
content='Git provider token required. (such as GitHub).',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
@app.get('/bitbucket/installations', response_model=list[str])
|
||||
async def get_user_bitbucket_installations(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
return await client.get_github_installations()
|
||||
|
||||
return JSONResponse(
|
||||
content='Git provider token required. (such as GitHub).',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
@app.get('/repositories', response_model=list[Repository])
|
||||
async def get_user_repositories(
|
||||
sort: str = 'pushed',
|
||||
selected_provider: ProviderType | None = None,
|
||||
page: int | None = None,
|
||||
per_page: int | None = None,
|
||||
installation_id: str | None = None,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
@@ -99,14 +53,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(
|
||||
|
||||
@@ -212,7 +212,7 @@ def _load_runtime(
|
||||
runtime_startup_env_vars: dict[str, str] | None = None,
|
||||
docker_runtime_kwargs: dict[str, str] | None = None,
|
||||
override_mcp_config: MCPConfig | None = None,
|
||||
enable_browser: bool = True,
|
||||
enable_browser: bool = False,
|
||||
) -> tuple[Runtime, OpenHandsConfig]:
|
||||
sid = 'rt_' + str(random.randint(100000, 999999))
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ def test_view_file(temp_dir, runtime_cls, run_as_openhands):
|
||||
|
||||
|
||||
def test_view_directory(temp_dir, runtime_cls, run_as_openhands):
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
runtime, config = _load_runtime(
|
||||
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
|
||||
)
|
||||
try:
|
||||
# Create test file
|
||||
test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt')
|
||||
|
||||
@@ -36,6 +36,7 @@ def test_browsergym_eval_env(runtime_cls, temp_dir):
|
||||
base_container_image='xingyaoww/od-eval-miniwob:v1.0',
|
||||
browsergym_eval_env='browsergym/miniwob.choose-list',
|
||||
force_rebuild_runtime=True,
|
||||
enable_browser=True,
|
||||
)
|
||||
from openhands.runtime.browser.browser_env import (
|
||||
BROWSER_EVAL_GET_GOAL_ACTION,
|
||||
|
||||
@@ -144,7 +144,9 @@ def test_browser_disabled(temp_dir, runtime_cls, run_as_openhands):
|
||||
|
||||
|
||||
def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
runtime, config = _load_runtime(
|
||||
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
|
||||
)
|
||||
|
||||
# Test browse
|
||||
action_cmd = CmdRunAction(command='python3 -m http.server 8000 > server.log 2>&1 &')
|
||||
@@ -189,7 +191,9 @@ def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
|
||||
|
||||
def test_browser_navigation_actions(temp_dir, runtime_cls, run_as_openhands):
|
||||
"""Test browser navigation actions: goto, go_back, go_forward, noop."""
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
runtime, config = _load_runtime(
|
||||
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
|
||||
)
|
||||
try:
|
||||
# Create test HTML pages
|
||||
page1_content = """
|
||||
@@ -322,7 +326,9 @@ def test_browser_navigation_actions(temp_dir, runtime_cls, run_as_openhands):
|
||||
|
||||
def test_browser_form_interactions(temp_dir, runtime_cls, run_as_openhands):
|
||||
"""Test browser form interaction actions: fill, click, select_option, clear."""
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
runtime, config = _load_runtime(
|
||||
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
|
||||
)
|
||||
try:
|
||||
# Create a test form page
|
||||
form_content = """
|
||||
@@ -536,7 +542,9 @@ fill("{textarea_bid}", "This is a test message")
|
||||
|
||||
def test_browser_interactive_actions(temp_dir, runtime_cls, run_as_openhands):
|
||||
"""Test browser interactive actions: scroll, hover, fill, press, focus."""
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
runtime, config = _load_runtime(
|
||||
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
|
||||
)
|
||||
try:
|
||||
# Create a test page with scrollable content
|
||||
scroll_content = """
|
||||
@@ -742,7 +750,9 @@ scroll(0, 400)
|
||||
|
||||
def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands):
|
||||
"""Test browser file upload action."""
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
runtime, config = _load_runtime(
|
||||
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
|
||||
)
|
||||
try:
|
||||
# Create a test file to upload
|
||||
test_file_content = 'This is a test file for upload testing.'
|
||||
@@ -897,7 +907,9 @@ def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands):
|
||||
|
||||
|
||||
def test_read_pdf_browse(temp_dir, runtime_cls, run_as_openhands):
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
runtime, config = _load_runtime(
|
||||
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
|
||||
)
|
||||
try:
|
||||
# Create a PDF file using reportlab in the host environment
|
||||
from reportlab.lib.pagesizes import letter
|
||||
@@ -969,7 +981,9 @@ def test_read_pdf_browse(temp_dir, runtime_cls, run_as_openhands):
|
||||
|
||||
|
||||
def test_read_png_browse(temp_dir, runtime_cls, run_as_openhands):
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
runtime, config = _load_runtime(
|
||||
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
|
||||
)
|
||||
try:
|
||||
# Create a PNG file using PIL in the host environment
|
||||
from PIL import Image, ImageDraw
|
||||
@@ -1037,7 +1051,9 @@ def test_read_png_browse(temp_dir, runtime_cls, run_as_openhands):
|
||||
|
||||
def test_download_file(temp_dir, runtime_cls, run_as_openhands):
|
||||
"""Test downloading a file using the browser."""
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
runtime, config = _load_runtime(
|
||||
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
|
||||
)
|
||||
try:
|
||||
# Minimal PDF content for testing
|
||||
pdf_content = b"""%PDF-1.4
|
||||
|
||||
@@ -128,7 +128,11 @@ async def test_fetch_mcp_via_stdio(temp_dir, runtime_cls, run_as_openhands):
|
||||
)
|
||||
override_mcp_config = MCPConfig(stdio_servers=[mcp_stdio_server_config])
|
||||
runtime, config = _load_runtime(
|
||||
temp_dir, runtime_cls, run_as_openhands, override_mcp_config=override_mcp_config
|
||||
temp_dir,
|
||||
runtime_cls,
|
||||
run_as_openhands,
|
||||
override_mcp_config=override_mcp_config,
|
||||
enable_browser=True,
|
||||
)
|
||||
|
||||
# Test browser server
|
||||
@@ -220,6 +224,7 @@ async def test_both_stdio_and_sse_mcp(
|
||||
runtime_cls,
|
||||
run_as_openhands,
|
||||
override_mcp_config=override_mcp_config,
|
||||
enable_browser=True,
|
||||
)
|
||||
|
||||
# ======= Test SSE server =======
|
||||
@@ -297,6 +302,7 @@ async def test_microagent_and_one_stdio_mcp_in_config(
|
||||
runtime_cls,
|
||||
run_as_openhands,
|
||||
override_mcp_config=override_mcp_config,
|
||||
enable_browser=True,
|
||||
)
|
||||
|
||||
# NOTE: this simulate the case where the microagent adds a new stdio server to the runtime
|
||||
|
||||
@@ -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,7 +619,7 @@ async def test_bitbucket_get_repositories_with_user_owner_type():
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_repos]
|
||||
|
||||
repositories = await service.get_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
|
||||
@@ -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
|
||||
@@ -746,7 +746,7 @@ async def test_bitbucket_get_repositories_owner_type_fallback():
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_repos]
|
||||
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify all repositories default to USER owner_type for private workspaces
|
||||
for repo in repositories:
|
||||
|
||||
@@ -4,12 +4,16 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from openhands.cli.main import alias_setup_declined as main_alias_setup_declined
|
||||
from openhands.cli.main import aliases_exist_in_shell_config, run_alias_setup_flow
|
||||
from openhands.cli.shell_config import (
|
||||
ShellConfigManager,
|
||||
add_aliases_to_shell_config,
|
||||
aliases_exist_in_shell_config,
|
||||
alias_setup_declined,
|
||||
get_shell_config_path,
|
||||
mark_alias_setup_declined,
|
||||
)
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
|
||||
|
||||
def test_get_shell_config_path_no_files_fallback():
|
||||
@@ -244,3 +248,121 @@ def test_shell_config_manager_template_rendering():
|
||||
assert 'test-command' in content
|
||||
assert 'alias openhands="test-command"' in content
|
||||
assert 'alias oh="test-command"' in content
|
||||
|
||||
|
||||
def test_alias_setup_declined_false():
|
||||
"""Test alias setup declined check when marker file doesn't exist."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
assert alias_setup_declined() is False
|
||||
|
||||
|
||||
def test_alias_setup_declined_true():
|
||||
"""Test alias setup declined check when marker file exists."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Create the marker file
|
||||
mark_alias_setup_declined()
|
||||
assert alias_setup_declined() is True
|
||||
|
||||
|
||||
def test_mark_alias_setup_declined():
|
||||
"""Test marking alias setup as declined creates the marker file."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Initially should be False
|
||||
assert alias_setup_declined() is False
|
||||
|
||||
# Mark as declined
|
||||
mark_alias_setup_declined()
|
||||
|
||||
# Should now be True
|
||||
assert alias_setup_declined() is True
|
||||
|
||||
# Verify the file exists
|
||||
marker_file = Path(temp_dir) / '.openhands' / '.cli_alias_setup_declined'
|
||||
assert marker_file.exists()
|
||||
|
||||
|
||||
def test_alias_setup_declined_persisted():
|
||||
"""Test that when user declines alias setup, their choice is persisted."""
|
||||
config = OpenHandsConfig()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
|
||||
with patch(
|
||||
'openhands.cli.shell_config.aliases_exist_in_shell_config',
|
||||
return_value=False,
|
||||
):
|
||||
with patch(
|
||||
'openhands.cli.main.cli_confirm', return_value=1
|
||||
): # User chooses "No"
|
||||
with patch('prompt_toolkit.print_formatted_text'):
|
||||
# Initially, user hasn't declined
|
||||
assert not alias_setup_declined()
|
||||
|
||||
# Run the alias setup flow
|
||||
run_alias_setup_flow(config)
|
||||
|
||||
# After declining, the marker should be set
|
||||
assert alias_setup_declined()
|
||||
|
||||
|
||||
def test_alias_setup_skipped_when_previously_declined():
|
||||
"""Test that alias setup is skipped when user has previously declined."""
|
||||
OpenHandsConfig()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Mark that user has previously declined
|
||||
mark_alias_setup_declined()
|
||||
assert alias_setup_declined()
|
||||
|
||||
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
|
||||
with patch(
|
||||
'openhands.cli.shell_config.aliases_exist_in_shell_config',
|
||||
return_value=False,
|
||||
):
|
||||
with patch('openhands.cli.main.cli_confirm'):
|
||||
with patch('prompt_toolkit.print_formatted_text'):
|
||||
# This should not show the setup flow since user previously declined
|
||||
# We test this by checking the main logic conditions
|
||||
|
||||
should_show = (
|
||||
not aliases_exist_in_shell_config()
|
||||
and not main_alias_setup_declined()
|
||||
)
|
||||
|
||||
assert not should_show, (
|
||||
'Alias setup should be skipped when user previously declined'
|
||||
)
|
||||
|
||||
|
||||
def test_alias_setup_accepted_does_not_set_declined_flag():
|
||||
"""Test that when user accepts alias setup, no declined marker is created."""
|
||||
config = OpenHandsConfig()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
|
||||
with patch(
|
||||
'openhands.cli.shell_config.aliases_exist_in_shell_config',
|
||||
return_value=False,
|
||||
):
|
||||
with patch(
|
||||
'openhands.cli.main.cli_confirm', return_value=0
|
||||
): # User chooses "Yes"
|
||||
with patch(
|
||||
'openhands.cli.shell_config.add_aliases_to_shell_config',
|
||||
return_value=True,
|
||||
):
|
||||
with patch('prompt_toolkit.print_formatted_text'):
|
||||
# Initially, user hasn't declined
|
||||
assert not alias_setup_declined()
|
||||
|
||||
# Run the alias setup flow
|
||||
run_alias_setup_flow(config)
|
||||
|
||||
# After accepting, the declined marker should still be False
|
||||
assert not alias_setup_declined()
|
||||
|
||||
167
tests/unit/test_git_handler_merge_fix.py
Normal file
167
tests/unit/test_git_handler_merge_fix.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
|
||||
|
||||
|
||||
class TestGitHandlerMergeFix(unittest.TestCase):
|
||||
"""Test the fix for git changes showing merged files as user changes."""
|
||||
|
||||
def setUp(self):
|
||||
# Create temporary directories for our test repositories
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.origin_dir = os.path.join(self.test_dir, 'origin')
|
||||
self.local_dir = os.path.join(self.test_dir, 'local')
|
||||
|
||||
# Create the directories
|
||||
os.makedirs(self.origin_dir, exist_ok=True)
|
||||
os.makedirs(self.local_dir, exist_ok=True)
|
||||
|
||||
# Track executed commands for verification
|
||||
self.executed_commands = []
|
||||
|
||||
# Initialize the GitHandler with our real execute function
|
||||
self.git_handler = GitHandler(self._execute_command)
|
||||
self.git_handler.set_cwd(self.local_dir)
|
||||
|
||||
# Set up the git repositories
|
||||
self._setup_git_repos()
|
||||
|
||||
def tearDown(self):
|
||||
# Clean up the temporary directories
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def _execute_command(self, cmd, cwd=None):
|
||||
"""Execute a shell command and return the result."""
|
||||
self.executed_commands.append((cmd, cwd))
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=False
|
||||
)
|
||||
return CommandResult(result.stdout, result.returncode)
|
||||
except Exception as e:
|
||||
return CommandResult(str(e), 1)
|
||||
|
||||
def _setup_git_repos(self):
|
||||
"""Set up git repositories for testing the merge scenario."""
|
||||
# Set up origin repository
|
||||
self._execute_command('git init --initial-branch=main', self.origin_dir)
|
||||
self._execute_command(
|
||||
"git config user.email 'test@example.com'", self.origin_dir
|
||||
)
|
||||
self._execute_command("git config user.name 'Test User'", self.origin_dir)
|
||||
|
||||
# Create initial file and commit
|
||||
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Initial content\n')
|
||||
|
||||
self._execute_command('git add file1.txt', self.origin_dir)
|
||||
self._execute_command("git commit -m 'Initial commit'", self.origin_dir)
|
||||
|
||||
# Clone to local
|
||||
self._execute_command(f'git clone {self.origin_dir} {self.local_dir}')
|
||||
self._execute_command(
|
||||
"git config user.email 'test@example.com'", self.local_dir
|
||||
)
|
||||
self._execute_command("git config user.name 'Test User'", self.local_dir)
|
||||
|
||||
# Create a feature branch
|
||||
self._execute_command('git checkout -b feature-branch', self.local_dir)
|
||||
|
||||
# Make some changes on feature branch
|
||||
with open(os.path.join(self.local_dir, 'feature_file.txt'), 'w') as f:
|
||||
f.write('Feature content\n')
|
||||
|
||||
self._execute_command('git add feature_file.txt', self.local_dir)
|
||||
self._execute_command("git commit -m 'Add feature file'", self.local_dir)
|
||||
|
||||
# Push feature branch to origin
|
||||
self._execute_command('git push -u origin feature-branch', self.local_dir)
|
||||
|
||||
# Now simulate main branch getting ahead
|
||||
# Switch to main in origin and add more commits
|
||||
self._execute_command('git checkout main', self.origin_dir)
|
||||
|
||||
with open(os.path.join(self.origin_dir, 'main_file1.txt'), 'w') as f:
|
||||
f.write('Main content 1\n')
|
||||
self._execute_command('git add main_file1.txt', self.origin_dir)
|
||||
self._execute_command("git commit -m 'Add main file 1'", self.origin_dir)
|
||||
|
||||
with open(os.path.join(self.origin_dir, 'main_file2.txt'), 'w') as f:
|
||||
f.write('Main content 2\n')
|
||||
self._execute_command('git add main_file2.txt', self.origin_dir)
|
||||
self._execute_command("git commit -m 'Add main file 2'", self.origin_dir)
|
||||
|
||||
def test_git_changes_before_merge(self):
|
||||
"""Test that git changes shows no changes before merge."""
|
||||
changes = self.git_handler.get_git_changes()
|
||||
self.assertEqual(changes, [])
|
||||
|
||||
def test_git_changes_after_merge_shows_only_user_changes(self):
|
||||
"""Test that git changes after merge shows only user changes, not merged files."""
|
||||
# First, fetch latest changes from main
|
||||
self._execute_command('git fetch origin', self.local_dir)
|
||||
|
||||
# Merge main into feature branch
|
||||
self._execute_command('git merge origin/main', self.local_dir)
|
||||
|
||||
# Clear executed commands to start fresh for the git handler calls
|
||||
self.executed_commands = []
|
||||
|
||||
# Get git changes after merge
|
||||
changes = self.git_handler.get_git_changes()
|
||||
|
||||
# Should only show the feature file, not the merged files
|
||||
self.assertIsNotNone(changes)
|
||||
self.assertEqual(len(changes), 1)
|
||||
self.assertEqual(changes[0]['path'], 'feature_file.txt')
|
||||
self.assertEqual(changes[0]['status'], 'A')
|
||||
|
||||
# Verify that the merged files are not shown as changes
|
||||
paths = [change['path'] for change in changes]
|
||||
self.assertNotIn('main_file1.txt', paths)
|
||||
self.assertNotIn('main_file2.txt', paths)
|
||||
|
||||
def test_divergence_detection_after_merge(self):
|
||||
"""Test that divergence detection correctly identifies merge scenarios."""
|
||||
# Before merge, should not detect divergence
|
||||
has_diverged_before = (
|
||||
self.git_handler._has_diverged_from_remote_tracking_branch(
|
||||
'feature-branch', 'main'
|
||||
)
|
||||
)
|
||||
self.assertFalse(has_diverged_before)
|
||||
|
||||
# Fetch and merge
|
||||
self._execute_command('git fetch origin', self.local_dir)
|
||||
self._execute_command('git merge origin/main', self.local_dir)
|
||||
|
||||
# After merge, should detect divergence
|
||||
has_diverged_after = self.git_handler._has_diverged_from_remote_tracking_branch(
|
||||
'feature-branch', 'main'
|
||||
)
|
||||
self.assertTrue(has_diverged_after)
|
||||
|
||||
def test_valid_ref_selection_after_merge(self):
|
||||
"""Test that _get_valid_ref selects merge-base after detecting divergence."""
|
||||
# Fetch and merge
|
||||
self._execute_command('git fetch origin', self.local_dir)
|
||||
self._execute_command('git merge origin/main', self.local_dir)
|
||||
|
||||
# Clear executed commands to start fresh
|
||||
self.executed_commands = []
|
||||
|
||||
# Get valid ref
|
||||
valid_ref = self.git_handler._get_valid_ref()
|
||||
|
||||
# Should use merge-base instead of remote tracking branch
|
||||
self.assertIsNotNone(valid_ref)
|
||||
self.assertIn('merge-base', valid_ref)
|
||||
self.assertNotEqual(valid_ref, 'origin/feature-branch')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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:
|
||||
|
||||
@@ -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,7 +162,7 @@ async def test_gitlab_get_repositories_owner_type_fallback():
|
||||
# Mock the pagination response
|
||||
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
|
||||
|
||||
repositories = await service.get_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:
|
||||
|
||||
@@ -101,7 +101,7 @@ def test_prep_build_folder(temp_dir):
|
||||
|
||||
def test_get_hash_for_lock_files():
|
||||
with patch('builtins.open', mock_open(read_data='mock-data'.encode())):
|
||||
hash = get_hash_for_lock_files('some_base_image')
|
||||
hash = get_hash_for_lock_files('some_base_image', enable_browser=True)
|
||||
# Since we mocked open to always return "mock_data", the hash is the result
|
||||
# of hashing the name of the base image followed by "mock-data" twice
|
||||
md5 = hashlib.md5()
|
||||
@@ -111,6 +111,31 @@ def test_get_hash_for_lock_files():
|
||||
assert hash == truncate_hash(md5.hexdigest())
|
||||
|
||||
|
||||
def test_get_hash_for_lock_files_different_enable_browser():
|
||||
with patch('builtins.open', mock_open(read_data='mock-data'.encode())):
|
||||
hash_true = get_hash_for_lock_files('some_base_image', enable_browser=True)
|
||||
hash_false = get_hash_for_lock_files('some_base_image', enable_browser=False)
|
||||
|
||||
# Hash with enable_browser=True should not include the enable_browser value
|
||||
md5_true = hashlib.md5()
|
||||
md5_true.update('some_base_image'.encode())
|
||||
for _ in range(2):
|
||||
md5_true.update('mock-data'.encode())
|
||||
expected_hash_true = truncate_hash(md5_true.hexdigest())
|
||||
|
||||
# Hash with enable_browser=False should include the enable_browser value
|
||||
md5_false = hashlib.md5()
|
||||
md5_false.update('some_base_image'.encode())
|
||||
md5_false.update('False'.encode()) # enable_browser=False is included
|
||||
for _ in range(2):
|
||||
md5_false.update('mock-data'.encode())
|
||||
expected_hash_false = truncate_hash(md5_false.hexdigest())
|
||||
|
||||
assert hash_true == expected_hash_true
|
||||
assert hash_false == expected_hash_false
|
||||
assert hash_true != hash_false # They should be different
|
||||
|
||||
|
||||
def test_get_hash_for_source_files():
|
||||
dirhash_mock = MagicMock()
|
||||
dirhash_mock.return_value = '1f69bd20d68d9e3874d5bf7f7459709b'
|
||||
@@ -247,7 +272,7 @@ def test_build_runtime_image_from_scratch():
|
||||
== f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
|
||||
)
|
||||
mock_prep_build_folder.assert_called_once_with(
|
||||
ANY, base_image, BuildFromImageType.SCRATCH, None
|
||||
ANY, base_image, BuildFromImageType.SCRATCH, None, True
|
||||
)
|
||||
|
||||
|
||||
@@ -342,6 +367,7 @@ def test_build_runtime_image_exact_hash_not_exist_and_lock_exist():
|
||||
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag',
|
||||
BuildFromImageType.LOCK,
|
||||
None,
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
@@ -401,6 +427,7 @@ def test_build_runtime_image_exact_hash_not_exist_and_lock_not_exist_and_version
|
||||
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-versioned-tag',
|
||||
BuildFromImageType.VERSIONED,
|
||||
None,
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ class ModalRuntime(ActionExecutionClient):
|
||||
# Read Modal API credentials from environment variables
|
||||
modal_token_id = os.getenv('MODAL_TOKEN_ID')
|
||||
modal_token_secret = os.getenv('MODAL_TOKEN_SECRET')
|
||||
|
||||
|
||||
if not modal_token_id:
|
||||
raise ValueError('MODAL_TOKEN_ID environment variable is required for Modal runtime')
|
||||
if not modal_token_secret:
|
||||
@@ -186,6 +186,7 @@ class ModalRuntime(ActionExecutionClient):
|
||||
base_image=base_container_image_id,
|
||||
build_from=BuildFromImageType.SCRATCH,
|
||||
extra_deps=runtime_extra_deps,
|
||||
enable_browser=True,
|
||||
)
|
||||
|
||||
base_runtime_image = modal.Image.from_dockerfile(
|
||||
|
||||
Reference in New Issue
Block a user