mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
update-gro
...
feature/or
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e2ed78ee2 | ||
|
|
5ac1fd9077 |
@@ -58,34 +58,34 @@ RUN sed -i 's/^UID_MIN.*/UID_MIN 499/' /etc/login.defs
|
||||
# Default is 60000, but we've seen up to 200000
|
||||
RUN sed -i 's/^UID_MAX.*/UID_MAX 1000000/' /etc/login.defs
|
||||
|
||||
RUN groupadd --gid $OPENHANDS_USER_ID openhands
|
||||
RUN groupadd --gid $OPENHANDS_USER_ID app
|
||||
RUN useradd -l -m -u $OPENHANDS_USER_ID --gid $OPENHANDS_USER_ID -s /bin/bash openhands && \
|
||||
usermod -aG openhands openhands && \
|
||||
usermod -aG app openhands && \
|
||||
usermod -aG sudo openhands && \
|
||||
echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
|
||||
RUN chown -R openhands:openhands /app && chmod -R 770 /app
|
||||
RUN sudo chown -R openhands:openhands $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
|
||||
RUN chown -R openhands:app /app && chmod -R 770 /app
|
||||
RUN sudo chown -R openhands:app $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
|
||||
USER openhands
|
||||
|
||||
ENV VIRTUAL_ENV=/app/.venv \
|
||||
PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONPATH='/app'
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
COPY --chown=openhands:app --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 ./microagents ./microagents
|
||||
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
COPY --chown=openhands:app --chmod=770 ./microagents ./microagents
|
||||
COPY --chown=openhands:app --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:app --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:app pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
|
||||
# This is run as "openhands" user, and will create __pycache__ with openhands:openhands ownership
|
||||
RUN python openhands/core/download.py # No-op to download assets
|
||||
# Add this line to set group ownership of all files/directories not already in "app" group
|
||||
# openhands:openhands -> openhands:openhands
|
||||
RUN find /app \! -group openhands -exec chgrp openhands {} +
|
||||
# openhands:openhands -> openhands:app
|
||||
RUN find /app \! -group app -exec chgrp app {} +
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 --from=frontend-builder /app/build ./frontend/build
|
||||
COPY --chown=openhands:openhands --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
|
||||
COPY --chown=openhands:app --chmod=770 --from=frontend-builder /app/build ./frontend/build
|
||||
COPY --chown=openhands:app --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
{
|
||||
"group": "Providers",
|
||||
"pages": [
|
||||
"usage/llms/openhands-llms",
|
||||
"usage/llms/azure-llms",
|
||||
"usage/llms/google-llms",
|
||||
"usage/llms/groq",
|
||||
@@ -79,6 +78,7 @@
|
||||
"usage/llms/litellm-proxy",
|
||||
"usage/llms/moonshot",
|
||||
"usage/llms/openai-llms",
|
||||
"usage/llms/openhands-llms",
|
||||
"usage/llms/openrouter"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -100,16 +100,6 @@ OpenHands requires an API key to access most language models. Here's how to get
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="OpenHands (Recommended)">
|
||||
|
||||
1. [Log in to OpenHands Cloud](https://app.all-hands.dev).
|
||||
2. Go to the Settings page and navigate to the `API Keys` tab.
|
||||
3. Copy your `LLM API Key`.
|
||||
|
||||
OpenHands provides access to state-of-the-art agentic coding models with competitive pricing. [Learn more about OpenHands LLM provider](/usage/llms/openhands-llms).
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Anthropic (Claude)">
|
||||
|
||||
1. [Create an Anthropic account](https://console.anthropic.com/).
|
||||
|
||||
@@ -85,36 +85,17 @@ describe("RepoConnector", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
|
||||
// Mock the search function that's used by the dropdown
|
||||
vi.spyOn(OpenHands, "searchGitRepositories").mockResolvedValue(
|
||||
MOCK_RESPOSITORIES,
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
// Wait for the loading state to be replaced with the dropdown
|
||||
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
|
||||
await userEvent.click(dropdown);
|
||||
|
||||
// Then interact with the repository dropdown
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
|
||||
expect(screen.getByText("All-Hands-AI/OpenHands")).toBeInTheDocument();
|
||||
screen.getByText("rbren/polaris");
|
||||
screen.getByText("All-Hands-AI/OpenHands");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,47 +104,18 @@ describe("RepoConnector", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
const launchButton = await screen.findByTestId("repo-launch-button");
|
||||
expect(launchButton).toBeDisabled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
|
||||
});
|
||||
// Wait for the loading state to be replaced with the dropdown
|
||||
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
|
||||
await userEvent.click(dropdown);
|
||||
await userEvent.click(screen.getByText("rbren/polaris"));
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(launchButton).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -228,10 +180,7 @@ describe("RepoConnector", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
@@ -243,37 +192,14 @@ describe("RepoConnector", () => {
|
||||
// repo not selected yet
|
||||
expect(createConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
// select a repository from the dropdown
|
||||
const dropdown = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(screen.getByText("rbren/polaris"));
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(dropdown);
|
||||
|
||||
const repoOption = screen.getByText("rbren/polaris");
|
||||
await userEvent.click(repoOption);
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
@@ -292,46 +218,17 @@ describe("RepoConnector", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
const launchButton = await screen.findByTestId("repo-launch-button");
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
|
||||
});
|
||||
// Wait for the loading state to be replaced with the dropdown
|
||||
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
|
||||
await userEvent.click(dropdown);
|
||||
await userEvent.click(screen.getByText("rbren/polaris"));
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
expect(launchButton).toBeDisabled();
|
||||
expect(launchButton).toHaveTextContent(/Loading/i);
|
||||
|
||||
@@ -12,8 +12,6 @@ const mockUseCreateConversation = vi.fn();
|
||||
const mockUseIsCreatingConversation = vi.fn();
|
||||
const mockUseTranslation = vi.fn();
|
||||
const mockUseAuth = vi.fn();
|
||||
const mockUseGitRepositories = vi.fn();
|
||||
const mockUseUserProviders = vi.fn();
|
||||
|
||||
// Setup default mock returns
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
@@ -32,29 +30,6 @@ mockUseIsCreatingConversation.mockReturnValue(false);
|
||||
|
||||
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
|
||||
|
||||
// Default mock for useGitRepositories
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: { pages: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
}));
|
||||
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: ["github"],
|
||||
});
|
||||
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
@@ -96,10 +71,6 @@ vi.mock("react-router", async (importActual) => ({
|
||||
useNavigate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-git-repositories", () => ({
|
||||
useGitRepositories: () => mockUseGitRepositories(),
|
||||
}));
|
||||
|
||||
const mockOnRepoSelection = vi.fn();
|
||||
const renderForm = () =>
|
||||
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
|
||||
@@ -125,6 +96,34 @@ describe("RepositorySelectionForm", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading indicator when repositories are being fetched", () => {
|
||||
const MOCK_REPOS: GitRepository[] = [
|
||||
{
|
||||
id: "1",
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "user/repo2",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
];
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
|
||||
|
||||
renderForm();
|
||||
|
||||
// Check if loading indicator is displayed
|
||||
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
|
||||
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows dropdown when repositories are loaded", async () => {
|
||||
const MOCK_REPOS: GitRepository[] = [
|
||||
{
|
||||
@@ -140,30 +139,24 @@ describe("RepositorySelectionForm", () => {
|
||||
is_public: true,
|
||||
},
|
||||
];
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: { pages: [{ data: MOCK_REPOS }] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
|
||||
|
||||
renderForm();
|
||||
expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message when repository fetch fails", async () => {
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockRejectedValue(
|
||||
new Error("Failed to load"),
|
||||
);
|
||||
|
||||
renderForm();
|
||||
|
||||
@@ -201,45 +194,40 @@ describe("RepositorySelectionForm", () => {
|
||||
];
|
||||
|
||||
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
|
||||
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
|
||||
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: { pages: [{ data: MOCK_REPOS }] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
providersAreSet: true,
|
||||
user: {
|
||||
id: 1,
|
||||
login: "testuser",
|
||||
avatar_url: "https://example.com/avatar.png",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
company: "Test Company",
|
||||
},
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
|
||||
|
||||
renderForm();
|
||||
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
const input = await screen.findByTestId("repo-dropdown");
|
||||
await userEvent.click(input);
|
||||
|
||||
for (const repo of MOCK_REPOS) {
|
||||
expect(screen.getByText(repo.full_name)).toBeInTheDocument();
|
||||
}
|
||||
expect(
|
||||
screen.queryByText(MOCK_SEARCH_REPOS[0].full_name),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(searchGitReposSpy).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
"kubernetes/kubernetes",
|
||||
3,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(MOCK_SEARCH_REPOS[0].full_name),
|
||||
).toBeInTheDocument();
|
||||
for (const repo of MOCK_REPOS) {
|
||||
expect(screen.queryByText(repo.full_name)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("should call onRepoSelection when a searched repository is selected", async () => {
|
||||
@@ -255,26 +243,20 @@ describe("RepositorySelectionForm", () => {
|
||||
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
|
||||
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
|
||||
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: { pages: [{ data: MOCK_SEARCH_REPOS }] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm();
|
||||
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
const input = await screen.findByTestId("repo-dropdown");
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
"kubernetes/kubernetes",
|
||||
3,
|
||||
);
|
||||
|
||||
const searchedRepo = screen.getByText(MOCK_SEARCH_REPOS[0].full_name);
|
||||
expect(searchedRepo).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(searchedRepo);
|
||||
expect(mockOnRepoSelection).toHaveBeenCalledWith(MOCK_SEARCH_REPOS[0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("TaskCard", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({ data: MOCK_RESPOSITORIES, nextPage: null });
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
});
|
||||
|
||||
it("should call create conversation with suggest task trigger and selected suggested task", async () => {
|
||||
|
||||
@@ -12,23 +12,6 @@ import { GitRepository } from "#/types/git";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
// Mock hooks
|
||||
const mockUseUserProviders = vi.fn();
|
||||
const mockUseUserRepositories = vi.fn();
|
||||
const mockUseConfig = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-user-repositories", () => ({
|
||||
useUserRepositories: () => mockUseUserRepositories(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => mockUseConfig(),
|
||||
}));
|
||||
|
||||
describe("MicroagentManagement", () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
@@ -168,39 +151,10 @@ describe("MicroagentManagement", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// Setup default hook mocks
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: ["github"],
|
||||
});
|
||||
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: mockRepositories,
|
||||
nextPage: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "oss",
|
||||
},
|
||||
});
|
||||
|
||||
// Setup default mock for retrieveUserGitRepositories
|
||||
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
|
||||
data: [...mockRepositories],
|
||||
nextPage: null,
|
||||
});
|
||||
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue([
|
||||
...mockRepositories,
|
||||
]);
|
||||
// Setup default mock for getRepositoryMicroagents
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagents").mockResolvedValue([
|
||||
...mockMicroagents,
|
||||
@@ -226,15 +180,13 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
|
||||
it("should display loading state when fetching repositories", async () => {
|
||||
// Mock loading state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockImplementation(
|
||||
() => new Promise(() => {}), // Never resolves
|
||||
);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
@@ -244,21 +196,19 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
|
||||
it("should handle error when fetching repositories", async () => {
|
||||
// Mock error state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockRejectedValue(
|
||||
new Error("Failed to fetch repositories"),
|
||||
);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for the error to be handled
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,7 +217,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that tabs are rendered
|
||||
@@ -285,7 +235,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded and rendered
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that repository names are displayed
|
||||
@@ -300,7 +250,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -337,7 +287,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -362,7 +312,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -387,7 +337,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -410,7 +360,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -449,7 +399,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that add microagent buttons are present
|
||||
@@ -463,7 +413,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -482,7 +432,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -502,28 +452,17 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
|
||||
it("should display empty state when no repositories are found", async () => {
|
||||
// Mock empty repositories
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: [],
|
||||
nextPage: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue([]);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that empty state messages are displayed
|
||||
@@ -540,7 +479,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -581,7 +520,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that search input is rendered
|
||||
@@ -601,7 +540,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Initially only repositories with .openhands should be visible
|
||||
@@ -632,7 +571,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with uppercase
|
||||
@@ -655,7 +594,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with partial match
|
||||
@@ -681,7 +620,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input
|
||||
@@ -714,7 +653,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with non-existent repository name
|
||||
@@ -742,7 +681,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with special characters
|
||||
@@ -763,7 +702,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Filter to show only repo2
|
||||
@@ -798,7 +737,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with leading/trailing whitespace
|
||||
@@ -818,7 +757,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
@@ -850,7 +789,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -877,7 +816,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -923,7 +862,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -940,7 +879,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -965,7 +904,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1014,7 +953,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1050,7 +989,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1092,7 +1031,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1129,7 +1068,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1173,7 +1112,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1203,7 +1142,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1226,7 +1165,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1253,7 +1192,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1294,7 +1233,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that add microagent buttons are present
|
||||
@@ -1308,7 +1247,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1363,7 +1302,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1387,7 +1326,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1410,7 +1349,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1443,7 +1382,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1470,7 +1409,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1496,7 +1435,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1575,8 +1514,8 @@ describe("MicroagentManagement", () => {
|
||||
pr_number: null,
|
||||
};
|
||||
|
||||
const renderMicroagentManagementMain = (selectedMicroagentItem: any) =>
|
||||
renderWithProviders(<MicroagentManagementMain />, {
|
||||
const renderMicroagentManagementMain = (selectedMicroagentItem: any) => {
|
||||
return renderWithProviders(<MicroagentManagementMain />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
@@ -1602,6 +1541,7 @@ describe("MicroagentManagement", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("should render MicroagentManagementDefault when no microagent or conversation is selected", async () => {
|
||||
renderMicroagentManagementMain(null);
|
||||
@@ -2355,7 +2295,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion to expand it
|
||||
@@ -2397,7 +2337,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories and expand accordion
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const repoAccordion = screen.getByTestId("repository-name-tooltip");
|
||||
@@ -2450,7 +2390,7 @@ describe("MicroagentManagement", () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const repoAccordion = screen.getByTestId("repository-name-tooltip");
|
||||
|
||||
@@ -5,32 +5,16 @@ import { UserActions } from "#/components/features/sidebar/user-actions";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
// Create mocks for all the hooks we need
|
||||
// Create a mock for useIsAuthed that we can control per test
|
||||
const useIsAuthedMock = vi
|
||||
.fn()
|
||||
.mockReturnValue({ data: true, isLoading: false });
|
||||
|
||||
const useConfigMock = vi
|
||||
.fn()
|
||||
.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
|
||||
const useUserProvidersMock = vi
|
||||
.fn()
|
||||
.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
// Mock the hooks
|
||||
// Mock the useIsAuthed hook
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => useIsAuthedMock(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => useConfigMock(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => useUserProvidersMock(),
|
||||
}));
|
||||
|
||||
describe("UserActions", () => {
|
||||
const user = userEvent.setup();
|
||||
const onClickAccountSettingsMock = vi.fn();
|
||||
@@ -56,10 +40,8 @@ describe("UserActions", () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks to default values before each test
|
||||
// Reset the mock to default value before each test
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -120,9 +102,6 @@ describe("UserActions", () => {
|
||||
it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => {
|
||||
// Set isAuthed to false for this test
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
@@ -152,9 +131,6 @@ describe("UserActions", () => {
|
||||
it("should NOT be able to access logout when user is not authenticated", async () => {
|
||||
// Set isAuthed to false for this test
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
@@ -173,9 +149,6 @@ describe("UserActions", () => {
|
||||
it("should handle user prop changing from undefined to defined", async () => {
|
||||
// Start with no authentication
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
const { rerender } = renderWithQueryClient(
|
||||
<UserActions onLogout={onLogoutMock} />,
|
||||
@@ -190,9 +163,6 @@ describe("UserActions", () => {
|
||||
|
||||
// Set authentication to true for the rerender
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
// Ensure config and providers are set correctly
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
// Add user prop and create a new QueryClient to ensure fresh state
|
||||
const queryClient = new QueryClient({
|
||||
@@ -225,11 +195,6 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
it("should handle user prop changing from defined to undefined", async () => {
|
||||
// Start with authentication and providers
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
const { rerender } = renderWithQueryClient(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
@@ -246,9 +211,6 @@ describe("UserActions", () => {
|
||||
|
||||
// Set authentication to false for the rerender
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
// Remove user prop - menu should disappear because user is no longer authenticated
|
||||
rerender(
|
||||
@@ -267,11 +229,6 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
it("should work with loading state and user provided", async () => {
|
||||
// Ensure authentication and providers are set correctly
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
renderWithQueryClient(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { createRoutesStub } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import GitSettingsScreen from "#/routes/git-settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
@@ -48,44 +46,22 @@ const GitSettingsRouterStub = createRoutesStub([
|
||||
]);
|
||||
|
||||
const renderGitSettingsScreen = () => {
|
||||
// Initialize i18next instance
|
||||
i18next.init({
|
||||
lng: "en",
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
GITHUB$TOKEN_HELP_TEXT: "Help text",
|
||||
GITHUB$TOKEN_LABEL: "GitHub Token",
|
||||
GITHUB$HOST_LABEL: "GitHub Host",
|
||||
GITLAB$TOKEN_LABEL: "GitLab Token",
|
||||
GITLAB$HOST_LABEL: "GitLab Host",
|
||||
BITBUCKET$TOKEN_LABEL: "Bitbucket Token",
|
||||
BITBUCKET$HOST_LABEL: "Bitbucket Host",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender, ...rest } = render(
|
||||
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<I18nextProvider i18n={i18next}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const rerenderGitSettingsScreen = () =>
|
||||
rerender(
|
||||
<I18nextProvider i18n={i18next}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -375,18 +351,14 @@ describe("Form submission", () => {
|
||||
let disconnectButton = await screen.findByTestId(
|
||||
"disconnect-tokens-button",
|
||||
);
|
||||
// When tokens are set (github and gitlab are not null), the button should be enabled
|
||||
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
|
||||
|
||||
// Mock settings with no tokens set
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {},
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
disconnectButton = await screen.findByTestId("disconnect-tokens-button");
|
||||
// When no tokens are set, the button should be disabled
|
||||
await waitFor(() => expect(disconnectButton).toBeDisabled());
|
||||
});
|
||||
|
||||
|
||||
@@ -32,42 +32,6 @@ const RouterStub = createRoutesStub([
|
||||
},
|
||||
]);
|
||||
|
||||
const selectRepository = async (repoName: string) => {
|
||||
const repoConnector = screen.getByTestId("repo-connector");
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
const repoInput = within(dropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
const options = screen.getAllByText(repoName);
|
||||
// Find the option in the dropdown (it will have role="option")
|
||||
const dropdownOption = options.find(
|
||||
(el) => el.getAttribute("role") === "option",
|
||||
);
|
||||
expect(dropdownOption).toBeInTheDocument();
|
||||
});
|
||||
const options = screen.getAllByText(repoName);
|
||||
const dropdownOption = options.find(
|
||||
(el) => el.getAttribute("role") === "option",
|
||||
);
|
||||
await userEvent.click(dropdownOption!);
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
const renderHomeScreen = () =>
|
||||
render(<RouterStub />, {
|
||||
wrapper: ({ children }) => (
|
||||
@@ -129,8 +93,84 @@ describe("HomeScreen", () => {
|
||||
expect(mainContainer).toHaveClass("flex", "flex-col", "lg:flex-row");
|
||||
});
|
||||
|
||||
// TODO: Fix this test
|
||||
it.skip("should filter and reset the suggested tasks based on repository selection", async () => {});
|
||||
it("should filter the suggested tasks based on the selected repository", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
|
||||
renderHomeScreen();
|
||||
|
||||
const taskSuggestions = await screen.findByTestId("task-suggestions");
|
||||
|
||||
// Initially, all tasks should be visible
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
within(taskSuggestions).getByText("octocat/earth");
|
||||
});
|
||||
|
||||
// Select a repository from the dropdown
|
||||
const repoConnector = screen.getByTestId("repo-connector");
|
||||
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
await userEvent.click(dropdown);
|
||||
|
||||
const repoOption = screen.getAllByText("octocat/hello-world")[1];
|
||||
await userEvent.click(repoOption);
|
||||
|
||||
// After selecting a repository, only tasks related to that repository should be visible
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
expect(
|
||||
within(taskSuggestions).queryByText("octocat/earth"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should reset the filtered tasks when the selected repository is cleared", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
|
||||
renderHomeScreen();
|
||||
|
||||
const taskSuggestions = await screen.findByTestId("task-suggestions");
|
||||
|
||||
// Initially, all tasks should be visible
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
within(taskSuggestions).getByText("octocat/earth");
|
||||
});
|
||||
|
||||
// Select a repository from the dropdown
|
||||
const repoConnector = screen.getByTestId("repo-connector");
|
||||
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
await userEvent.click(dropdown);
|
||||
|
||||
const repoOption = screen.getAllByText("octocat/hello-world")[1];
|
||||
await userEvent.click(repoOption);
|
||||
|
||||
// After selecting a repository, only tasks related to that repository should be visible
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
expect(
|
||||
within(taskSuggestions).queryByText("octocat/earth"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Clear the selected repository
|
||||
await userEvent.clear(dropdown);
|
||||
|
||||
// All tasks should be visible again
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
within(taskSuggestions).getByText("octocat/earth");
|
||||
});
|
||||
});
|
||||
|
||||
describe("launch buttons", () => {
|
||||
const setupLaunchButtons = async () => {
|
||||
@@ -139,25 +179,19 @@ describe("HomeScreen", () => {
|
||||
let tasksLaunchButtons =
|
||||
await screen.findAllByTestId("task-launch-button");
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
// Select a repository from the dropdown to enable the repo launch button
|
||||
const repoConnector = screen.getByTestId("repo-connector");
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
await userEvent.click(dropdown);
|
||||
const repoOption = screen.getAllByText("octocat/hello-world")[1];
|
||||
await userEvent.click(repoOption);
|
||||
|
||||
// Select a repository to enable the repo launch button
|
||||
await selectRepository("octocat/hello-world");
|
||||
|
||||
// Wait for all buttons to be enabled
|
||||
await waitFor(() => {
|
||||
expect(headerLaunchButton).not.toBeDisabled();
|
||||
expect(repoLaunchButton).not.toBeDisabled();
|
||||
tasksLaunchButtons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
expect(headerLaunchButton).not.toBeDisabled();
|
||||
expect(repoLaunchButton).not.toBeDisabled();
|
||||
tasksLaunchButtons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Get fresh references to the buttons
|
||||
headerLaunchButton = screen.getByTestId("header-launch-button");
|
||||
repoLaunchButton = screen.getByTestId("repo-launch-button");
|
||||
tasksLaunchButtons = await screen.findAllByTestId("task-launch-button");
|
||||
@@ -174,10 +208,7 @@ describe("HomeScreen", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
});
|
||||
|
||||
it("should disable the other launch buttons when the header launch button is clicked", async () => {
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("Content", () => {
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenHands");
|
||||
expect(provider).toHaveValue("Anthropic");
|
||||
expect(model).toHaveValue("claude-sonnet-4-20250514");
|
||||
|
||||
expect(apiKey).toHaveValue("");
|
||||
@@ -135,7 +135,7 @@ describe("Content", () => {
|
||||
);
|
||||
const condensor = screen.getByTestId("enable-memory-condenser-switch");
|
||||
|
||||
expect(model).toHaveValue("openhands/claude-sonnet-4-20250514");
|
||||
expect(model).toHaveValue("anthropic/claude-sonnet-4-20250514");
|
||||
expect(baseUrl).toHaveValue("");
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(apiKey).toHaveProperty("placeholder", "");
|
||||
@@ -537,7 +537,7 @@ describe("Form submission", () => {
|
||||
|
||||
// select provider
|
||||
await userEvent.click(provider);
|
||||
const providerOption = screen.getByText("OpenHands");
|
||||
const providerOption = screen.getByText("Anthropic");
|
||||
await userEvent.click(providerOption);
|
||||
|
||||
// select model
|
||||
@@ -550,7 +550,7 @@ describe("Form submission", () => {
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_model: "openhands/claude-sonnet-4-20250514",
|
||||
llm_model: "anthropic/claude-sonnet-4-20250514",
|
||||
llm_base_url: "",
|
||||
confirmation_mode: false,
|
||||
}),
|
||||
|
||||
@@ -112,21 +112,12 @@ describe("Content", () => {
|
||||
screen.getByTestId("git-settings-screen");
|
||||
});
|
||||
|
||||
it("should render an empty table when there are no existing secrets", async () => {
|
||||
it("should render a message if there are no existing secrets", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
getSecretsSpy.mockResolvedValue([]);
|
||||
renderSecretsSettings();
|
||||
|
||||
// Should show the add secret button
|
||||
await screen.findByTestId("add-secret-button");
|
||||
|
||||
// Should show an empty table with headers but no secret items
|
||||
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
|
||||
|
||||
// Should still show the table headers
|
||||
expect(screen.getByText("SETTINGS$NAME")).toBeInTheDocument();
|
||||
expect(screen.getByText("SECRETS$DESCRIPTION")).toBeInTheDocument();
|
||||
expect(screen.getByText("SETTINGS$ACTIONS")).toBeInTheDocument();
|
||||
await screen.findByTestId("no-secrets-message");
|
||||
});
|
||||
|
||||
it("should render existing secrets", async () => {
|
||||
@@ -136,6 +127,7 @@ describe("Content", () => {
|
||||
|
||||
const secrets = await screen.findAllByTestId("secret-item");
|
||||
expect(secrets).toHaveLength(2);
|
||||
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -407,22 +399,19 @@ describe("Secret actions", () => {
|
||||
expect(screen.queryByText("My_Secret_2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide the table and add button when in form view", async () => {
|
||||
it("should hide the no items message when in form view", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
getSecretsSpy.mockResolvedValue([]);
|
||||
renderSecretsSettings();
|
||||
|
||||
// Initially should show the add button and table
|
||||
// render form & hide items
|
||||
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
|
||||
const button = await screen.findByTestId("add-secret-button");
|
||||
expect(screen.getByText("SETTINGS$NAME")).toBeInTheDocument(); // table header
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
// When in form view, should hide the add button and table
|
||||
const secretForm = screen.getByTestId("add-secret-form");
|
||||
expect(secretForm).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SETTINGS$NAME")).not.toBeInTheDocument(); // table header should be hidden
|
||||
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not allow spaces in secret names", async () => {
|
||||
|
||||
1803
frontend/package-lock.json
generated
1803
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,15 +8,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.2",
|
||||
"@heroui/use-infinite-scroll": "^2.2.10",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.7.1",
|
||||
"@react-router/serve": "^7.7.1",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.9.0",
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@stripe/react-stripe-js": "^3.8.1",
|
||||
"@stripe/stripe-js": "^7.7.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.84.1",
|
||||
@@ -44,7 +43,6 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.7.1",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-breaks": "^4.0.0",
|
||||
@@ -94,7 +92,7 @@
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.2.0",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -112,13 +110,13 @@
|
||||
"eslint-plugin-i18next": "^6.1.3",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.1.4",
|
||||
"lint-staged": "^16.1.2",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.6.2",
|
||||
"stripe": "^18.4.0",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.10.4'
|
||||
const PACKAGE_VERSION = '2.10.3'
|
||||
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
@@ -20,7 +20,6 @@ import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import { GitUser, GitRepository, Branch } from "#/types/git";
|
||||
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
|
||||
|
||||
@@ -282,7 +281,7 @@ class OpenHands {
|
||||
|
||||
static async getUserConversations(): Promise<Conversation[]> {
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/conversations?limit=100",
|
||||
"/api/conversations?limit=20",
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
@@ -435,7 +434,6 @@ class OpenHands {
|
||||
static async searchGitRepositories(
|
||||
query: string,
|
||||
per_page = 5,
|
||||
selected_provider?: Provider,
|
||||
): Promise<GitRepository[]> {
|
||||
const response = await openHands.get<GitRepository[]>(
|
||||
"/api/user/search/repositories",
|
||||
@@ -443,7 +441,6 @@ class OpenHands {
|
||||
params: {
|
||||
query,
|
||||
per_page,
|
||||
selected_provider,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -488,70 +485,20 @@ class OpenHands {
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a PAT, retrieves the repositories of the user
|
||||
* @returns A list of repositories
|
||||
*/
|
||||
static async retrieveUserGitRepositories(
|
||||
selected_provider: Provider,
|
||||
page = 1,
|
||||
per_page = 30,
|
||||
) {
|
||||
static async retrieveUserGitRepositories() {
|
||||
const { data } = await openHands.get<GitRepository[]>(
|
||||
"/api/user/repositories",
|
||||
{
|
||||
params: {
|
||||
selected_provider,
|
||||
sort: "pushed",
|
||||
page,
|
||||
per_page,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const link =
|
||||
data.length > 0 && data[0].link_header ? data[0].link_header : "";
|
||||
const nextPage = extractNextPageFromLink(link);
|
||||
|
||||
return { data, nextPage };
|
||||
}
|
||||
|
||||
static async retrieveInstallationRepositories(
|
||||
selected_provider: Provider,
|
||||
installationIndex: number,
|
||||
installations: string[],
|
||||
page = 1,
|
||||
per_page = 30,
|
||||
) {
|
||||
const installationId = installations[installationIndex];
|
||||
const response = await openHands.get<GitRepository[]>(
|
||||
"/api/user/repositories",
|
||||
{
|
||||
params: {
|
||||
selected_provider,
|
||||
sort: "pushed",
|
||||
page,
|
||||
per_page,
|
||||
installation_id: installationId,
|
||||
},
|
||||
},
|
||||
);
|
||||
const link =
|
||||
response.data.length > 0 && response.data[0].link_header
|
||||
? response.data[0].link_header
|
||||
: "";
|
||||
const nextPage = extractNextPageFromLink(link);
|
||||
let nextInstallation: number | null;
|
||||
if (nextPage) {
|
||||
nextInstallation = installationIndex;
|
||||
} else if (installationIndex + 1 < installations.length) {
|
||||
nextInstallation = installationIndex + 1;
|
||||
} else {
|
||||
nextInstallation = null;
|
||||
}
|
||||
return {
|
||||
data: response.data,
|
||||
nextPage,
|
||||
installationIndex: nextInstallation,
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getRepositoryBranches(repository: string): Promise<Branch[]> {
|
||||
@@ -639,18 +586,6 @@ class OpenHands {
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user installation IDs
|
||||
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
|
||||
* @returns List of installation IDs
|
||||
*/
|
||||
static async getUserInstallationIds(provider: Provider): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>(
|
||||
`/api/user/installations?provider=${provider}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -51,7 +51,6 @@ export interface GetConfigResponse {
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
STRIPE_PUBLISHABLE_KEY?: string;
|
||||
PROVIDERS_CONFIGURED?: Provider[];
|
||||
AUTH_URL?: string;
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: boolean;
|
||||
HIDE_LLM_SETTINGS: boolean;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useRepositoryBranches } from "../../hooks/query/use-repository-branches";
|
||||
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
|
||||
|
||||
export interface GitBranchDropdownProps {
|
||||
repositoryName?: string | null;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (branchName: string | null) => void;
|
||||
}
|
||||
|
||||
export function GitBranchDropdown({
|
||||
repositoryName,
|
||||
value,
|
||||
placeholder = "Select branch...",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitBranchDropdownProps) {
|
||||
const { data: branches, isLoading } = useRepositoryBranches(
|
||||
repositoryName || null,
|
||||
);
|
||||
|
||||
const options: SelectOption[] = useMemo(
|
||||
() =>
|
||||
branches?.map((branch) => ({
|
||||
value: branch.name,
|
||||
label: branch.name,
|
||||
})) || [],
|
||||
[branches],
|
||||
);
|
||||
|
||||
const hasNoBranches = !isLoading && branches && branches.length === 0;
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((option) => option.value === value) || null,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const handleChange = (option: SelectOption | null) => {
|
||||
onChange?.(option?.value || null);
|
||||
};
|
||||
|
||||
const isDisabled = disabled || !repositoryName || isLoading || hasNoBranches;
|
||||
|
||||
const displayPlaceholder = hasNoBranches ? "No branches found" : placeholder;
|
||||
const displayErrorMessage = hasNoBranches
|
||||
? "This repository has no branches"
|
||||
: errorMessage;
|
||||
|
||||
return (
|
||||
<ReactSelectDropdown
|
||||
options={options}
|
||||
value={selectedOption}
|
||||
placeholder={displayPlaceholder}
|
||||
className={className}
|
||||
errorMessage={displayErrorMessage}
|
||||
disabled={isDisabled}
|
||||
isClearable={false}
|
||||
isSearchable
|
||||
isLoading={isLoading}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { Provider } from "../../types/settings";
|
||||
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
|
||||
|
||||
export interface GitProviderDropdownProps {
|
||||
providers: Provider[];
|
||||
value?: Provider | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
onChange?: (provider: Provider | null) => void;
|
||||
}
|
||||
|
||||
export function GitProviderDropdown({
|
||||
providers,
|
||||
value,
|
||||
placeholder = "Select Provider",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
}: GitProviderDropdownProps) {
|
||||
const options: SelectOption[] = useMemo(
|
||||
() =>
|
||||
providers.map((provider) => ({
|
||||
value: provider,
|
||||
label: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||
})),
|
||||
[providers],
|
||||
);
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((option) => option.value === value) || null,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const handleChange = (option: SelectOption | null) => {
|
||||
onChange?.(option?.value as Provider | null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactSelectDropdown
|
||||
options={options}
|
||||
value={selectedOption}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
errorMessage={errorMessage}
|
||||
disabled={disabled}
|
||||
isClearable={false}
|
||||
isSearchable={false}
|
||||
isLoading={isLoading}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../../types/settings";
|
||||
import { useGitRepositories } from "../../hooks/query/use-git-repositories";
|
||||
import OpenHands from "../../api/open-hands";
|
||||
import { GitRepository } from "../../types/git";
|
||||
import {
|
||||
ReactSelectAsyncDropdown,
|
||||
AsyncSelectOption,
|
||||
} from "./react-select-async-dropdown";
|
||||
|
||||
export interface GitRepositoryDropdownProps {
|
||||
provider: Provider;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (repository?: GitRepository) => void;
|
||||
}
|
||||
|
||||
interface SearchCache {
|
||||
[key: string]: GitRepository[];
|
||||
}
|
||||
|
||||
export function GitRepositoryDropdown({
|
||||
provider,
|
||||
value,
|
||||
placeholder = "Search repositories...",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitRepositoryDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useGitRepositories({
|
||||
provider,
|
||||
enabled: !disabled,
|
||||
});
|
||||
|
||||
const allOptions: AsyncSelectOption[] = useMemo(
|
||||
() =>
|
||||
data?.pages
|
||||
? data.pages.flatMap((page) =>
|
||||
page.data.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
})),
|
||||
)
|
||||
: [],
|
||||
[data],
|
||||
);
|
||||
|
||||
// Keep track of search results
|
||||
const searchCache = useRef<SearchCache>({});
|
||||
|
||||
const selectedOption = useMemo(() => {
|
||||
// First check in loaded pages
|
||||
const option = allOptions.find((opt) => opt.value === value);
|
||||
if (option) return option;
|
||||
|
||||
// If not found, check in search cache
|
||||
const repo = Object.values(searchCache.current)
|
||||
.flat()
|
||||
.find((r) => r.id === value);
|
||||
|
||||
if (repo) {
|
||||
return {
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [allOptions, value]);
|
||||
|
||||
const loadOptions = useCallback(
|
||||
async (inputValue: string): Promise<AsyncSelectOption[]> => {
|
||||
// If empty input, show all loaded options
|
||||
if (!inputValue.trim()) {
|
||||
return allOptions;
|
||||
}
|
||||
|
||||
// If it looks like a URL, extract the repo name and search
|
||||
if (inputValue.startsWith("https://")) {
|
||||
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
|
||||
if (match) {
|
||||
const repoName = match[1];
|
||||
const searchResults = await OpenHands.searchGitRepositories(
|
||||
repoName,
|
||||
3,
|
||||
);
|
||||
// Cache the search results
|
||||
searchCache.current[repoName] = searchResults;
|
||||
return searchResults.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// For any other input, search via API
|
||||
if (inputValue.length >= 2) {
|
||||
// Only search if at least 2 characters
|
||||
const searchResults = await OpenHands.searchGitRepositories(
|
||||
inputValue,
|
||||
10,
|
||||
);
|
||||
// Cache the search results
|
||||
searchCache.current[inputValue] = searchResults;
|
||||
return searchResults.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
}));
|
||||
}
|
||||
|
||||
// For very short inputs, do local filtering
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
},
|
||||
[allOptions],
|
||||
);
|
||||
|
||||
const handleChange = (option: AsyncSelectOption | null) => {
|
||||
if (!option) {
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// First check in loaded pages
|
||||
let repo = data?.pages
|
||||
?.flatMap((p) => p.data)
|
||||
.find((r) => r.id === option.value);
|
||||
|
||||
// If not found, check in search results
|
||||
if (!repo) {
|
||||
repo = Object.values(searchCache.current)
|
||||
.flat()
|
||||
.find((r) => r.id === option.value);
|
||||
}
|
||||
|
||||
onChange?.(repo);
|
||||
};
|
||||
|
||||
const handleMenuScrollToBottom = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage && !isLoading) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, isLoading, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactSelectAsyncDropdown
|
||||
testId="repo-dropdown"
|
||||
loadOptions={loadOptions}
|
||||
value={selectedOption}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
errorMessage={errorMessage}
|
||||
disabled={disabled}
|
||||
isClearable={false}
|
||||
isLoading={isLoading || isLoading || isFetchingNextPage}
|
||||
cacheOptions
|
||||
defaultOptions={allOptions}
|
||||
onChange={handleChange}
|
||||
onMenuScrollToBottom={handleMenuScrollToBottom}
|
||||
/>
|
||||
{isError && (
|
||||
<div
|
||||
data-testid="repo-dropdown-error"
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import AsyncSelect from "react-select/async";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
|
||||
|
||||
export type AsyncSelectOption = SelectOptionBase;
|
||||
|
||||
export interface ReactSelectAsyncDropdownProps {
|
||||
loadOptions: (inputValue: string) => Promise<AsyncSelectOption[]>;
|
||||
testId?: string;
|
||||
placeholder?: string;
|
||||
value?: AsyncSelectOption | null;
|
||||
defaultValue?: AsyncSelectOption | null;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isClearable?: boolean;
|
||||
isLoading?: boolean;
|
||||
cacheOptions?: boolean;
|
||||
defaultOptions?: boolean | AsyncSelectOption[];
|
||||
onChange?: (option: AsyncSelectOption | null) => void;
|
||||
onMenuScrollToBottom?: () => void;
|
||||
}
|
||||
|
||||
export function ReactSelectAsyncDropdown({
|
||||
loadOptions,
|
||||
testId,
|
||||
placeholder = "Search...",
|
||||
value,
|
||||
defaultValue,
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isClearable = false,
|
||||
isLoading = false,
|
||||
cacheOptions = true,
|
||||
defaultOptions = true,
|
||||
onChange,
|
||||
onMenuScrollToBottom,
|
||||
}: ReactSelectAsyncDropdownProps) {
|
||||
const customStyles = useMemo(() => getCustomStyles<AsyncSelectOption>(), []);
|
||||
|
||||
const handleLoadOptions = useCallback(
|
||||
(inputValue: string, callback: (options: AsyncSelectOption[]) => void) => {
|
||||
loadOptions(inputValue)
|
||||
.then((options) => callback(options))
|
||||
.catch(() => callback([]));
|
||||
},
|
||||
[loadOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid={testId} className={cn("w-full", className)}>
|
||||
<AsyncSelect
|
||||
loadOptions={handleLoadOptions}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
isLoading={isLoading}
|
||||
cacheOptions={cacheOptions}
|
||||
defaultOptions={defaultOptions}
|
||||
onChange={onChange}
|
||||
onMenuScrollToBottom={onMenuScrollToBottom}
|
||||
styles={customStyles}
|
||||
className="w-full"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p
|
||||
data-testid="repo-dropdown-error"
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import Select from "react-select";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
|
||||
|
||||
export type SelectOption = SelectOptionBase;
|
||||
|
||||
export interface ReactSelectDropdownProps {
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
value?: SelectOption | null;
|
||||
defaultValue?: SelectOption | null;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isClearable?: boolean;
|
||||
isSearchable?: boolean;
|
||||
isLoading?: boolean;
|
||||
onChange?: (option: SelectOption | null) => void;
|
||||
}
|
||||
|
||||
export function ReactSelectDropdown({
|
||||
options,
|
||||
placeholder = "Select option...",
|
||||
value,
|
||||
defaultValue,
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isClearable = false,
|
||||
isSearchable = true,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
}: ReactSelectDropdownProps) {
|
||||
const customStyles = useMemo(() => getCustomStyles<SelectOption>(), []);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<Select
|
||||
options={options}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
isSearchable={isSearchable}
|
||||
isLoading={isLoading}
|
||||
onChange={onChange}
|
||||
styles={customStyles}
|
||||
className="w-full"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p className="text-red-500 text-sm mt-1">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { StylesConfig } from "react-select";
|
||||
|
||||
export interface SelectOptionBase {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const getCustomStyles = <T extends SelectOptionBase>(): StylesConfig<
|
||||
T,
|
||||
false
|
||||
> => ({
|
||||
control: (provided, state) => ({
|
||||
...provided,
|
||||
backgroundColor: state.isDisabled ? "#363636" : "#454545", // darker tertiary when disabled
|
||||
border: "1px solid #717888",
|
||||
borderRadius: "0.125rem",
|
||||
minHeight: "2.5rem",
|
||||
padding: "0 0.5rem",
|
||||
boxShadow: state.isFocused ? "0 0 0 1px #717888" : "none",
|
||||
opacity: state.isDisabled ? 0.6 : 1,
|
||||
cursor: state.isDisabled ? "not-allowed" : "pointer",
|
||||
"&:hover": {
|
||||
borderColor: "#717888",
|
||||
},
|
||||
}),
|
||||
input: (provided) => ({
|
||||
...provided,
|
||||
color: "#ECEDEE", // content
|
||||
}),
|
||||
placeholder: (provided) => ({
|
||||
...provided,
|
||||
fontStyle: "italic",
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
}),
|
||||
singleValue: (provided, state) => ({
|
||||
...provided,
|
||||
color: state.isDisabled ? "#B7BDC2" : "#ECEDEE", // tertiary-light when disabled, content otherwise
|
||||
}),
|
||||
menu: (provided) => ({
|
||||
...provided,
|
||||
backgroundColor: "#454545", // tertiary
|
||||
border: "1px solid #717888",
|
||||
borderRadius: "0.75rem",
|
||||
overflow: "hidden", // ensure menu items don't overflow rounded corners
|
||||
}),
|
||||
menuList: (provided) => ({
|
||||
...provided,
|
||||
padding: "0.25rem", // add some padding around menu items
|
||||
}),
|
||||
option: (provided, state) => {
|
||||
let backgroundColor = "transparent";
|
||||
if (state.isSelected) {
|
||||
backgroundColor = "#C9B974"; // primary for selected
|
||||
} else if (state.isFocused) {
|
||||
backgroundColor = "#24272E"; // base-secondary for hover/focus
|
||||
}
|
||||
|
||||
return {
|
||||
...provided,
|
||||
backgroundColor,
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE", // black text on yellow, white on gray
|
||||
borderRadius: "0.5rem", // rounded menu items
|
||||
margin: "0.125rem 0", // small gap between items
|
||||
"&:hover": {
|
||||
backgroundColor: state.isSelected ? "#C9B974" : "#24272E", // keep yellow if selected, else gray
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE", // maintain text color on hover
|
||||
},
|
||||
"&:active": {
|
||||
backgroundColor: state.isSelected ? "#C9B974" : "#24272E",
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE",
|
||||
},
|
||||
};
|
||||
},
|
||||
clearIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
"&:hover": {
|
||||
color: "#ECEDEE", // content
|
||||
},
|
||||
}),
|
||||
dropdownIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
"&:hover": {
|
||||
color: "#ECEDEE", // content
|
||||
},
|
||||
}),
|
||||
loadingIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
}),
|
||||
});
|
||||
@@ -55,7 +55,7 @@ export function ChatMessage({
|
||||
className={cn(
|
||||
"rounded-xl relative w-fit",
|
||||
"flex flex-col gap-2",
|
||||
type === "user" && " p-4 bg-tertiary self-end",
|
||||
type === "user" && " max-w-[305px] p-4 bg-tertiary self-end",
|
||||
type === "agent" && "mt-6 max-w-full bg-transparent",
|
||||
)}
|
||||
>
|
||||
@@ -86,13 +86,7 @@ export function ChatMessage({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-sm"
|
||||
style={{
|
||||
whiteSpace: "normal",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
<div className="text-sm break-words">
|
||||
<Markdown
|
||||
components={{
|
||||
code,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaInfoCircle } from "react-icons/fa";
|
||||
import { ConnectToProviderMessage } from "./connect-to-provider-message";
|
||||
import { RepositorySelectionForm } from "./repo-selection-form";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { RepoProviderLinks } from "./repo-provider-links";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
|
||||
interface RepoConnectorProps {
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
@@ -25,19 +23,7 @@ export function RepoConnector({ onRepoSelection }: RepoConnectorProps) {
|
||||
data-testid="repo-connector"
|
||||
className="w-full flex flex-col gap-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="heading">{t("HOME$CONNECT_TO_REPOSITORY")}</h2>
|
||||
<TooltipButton
|
||||
testId="repo-connector-info"
|
||||
tooltip={t("HOME$CONNECT_TO_REPOSITORY_TOOLTIP")}
|
||||
ariaLabel={t("HOME$CONNECT_TO_REPOSITORY_TOOLTIP")}
|
||||
className="text-[#9099AC] hover:text-white"
|
||||
placement="bottom"
|
||||
tooltipClassName="max-w-[348px]"
|
||||
>
|
||||
<FaInfoCircle size={16} />
|
||||
</TooltipButton>
|
||||
</div>
|
||||
<h2 className="heading">{t("HOME$CONNECT_TO_REPOSITORY")}</h2>
|
||||
|
||||
{!providersAreSet && <ConnectToProviderMessage />}
|
||||
{providersAreSet && (
|
||||
|
||||
@@ -2,15 +2,22 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { Branch, GitRepository } from "#/types/git";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitProviderDropdown } from "../../common/git-provider-dropdown";
|
||||
import { GitRepositoryDropdown } from "../../common/git-repository-dropdown";
|
||||
import { GitBranchDropdown } from "../../common/git-branch-dropdown";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import {
|
||||
RepositoryDropdown,
|
||||
RepositoryLoadingState,
|
||||
RepositoryErrorState,
|
||||
BranchDropdown,
|
||||
BranchLoadingState,
|
||||
BranchErrorState,
|
||||
} from "./repository-selection";
|
||||
|
||||
interface RepositorySelectionFormProps {
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
@@ -25,11 +32,18 @@ export function RepositorySelectionForm({
|
||||
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
React.useState<Provider | null>(null);
|
||||
const { providers } = useUserProviders();
|
||||
const { data: branches, isLoading: isLoadingBranches } =
|
||||
useRepositoryBranches(selectedRepository?.full_name || null);
|
||||
// Add a ref to track if the branch was manually cleared by the user
|
||||
const branchManuallyClearedRef = React.useRef<boolean>(false);
|
||||
const {
|
||||
data: repositories,
|
||||
isLoading: isLoadingRepositories,
|
||||
isError: isRepositoriesError,
|
||||
} = useUserRepositories();
|
||||
const {
|
||||
data: branches,
|
||||
isLoading: isLoadingBranches,
|
||||
isError: isBranchesError,
|
||||
} = useRepositoryBranches(selectedRepository?.full_name || null);
|
||||
const {
|
||||
mutate: createConversation,
|
||||
isPending,
|
||||
@@ -38,108 +52,151 @@ export function RepositorySelectionForm({
|
||||
const isCreatingConversationElsewhere = useIsCreatingConversation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Auto-select provider if there's only one
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery);
|
||||
|
||||
// Auto-select main or master branch if it exists, but only if the branch wasn't manually cleared
|
||||
React.useEffect(() => {
|
||||
if (providers.length === 1 && !selectedProvider) {
|
||||
setSelectedProvider(providers[0]);
|
||||
if (
|
||||
branches &&
|
||||
branches.length > 0 &&
|
||||
!selectedBranch &&
|
||||
!isLoadingBranches &&
|
||||
!branchManuallyClearedRef.current // Only auto-select if not manually cleared
|
||||
) {
|
||||
// Look for main or master branch
|
||||
const mainBranch = branches.find((branch) => branch.name === "main");
|
||||
const masterBranch = branches.find((branch) => branch.name === "master");
|
||||
|
||||
// Select main if it exists, otherwise select master if it exists
|
||||
if (mainBranch) {
|
||||
setSelectedBranch(mainBranch);
|
||||
} else if (masterBranch) {
|
||||
setSelectedBranch(masterBranch);
|
||||
}
|
||||
}
|
||||
}, [providers, selectedProvider]);
|
||||
}, [branches, isLoadingBranches, selectedBranch]);
|
||||
|
||||
// We check for isSuccess because the app might require time to render
|
||||
// into the new conversation screen after the conversation is created.
|
||||
const isCreatingConversation =
|
||||
isPending || isSuccess || isCreatingConversationElsewhere;
|
||||
|
||||
// Check if repository has no branches (empty array after loading completes)
|
||||
const hasNoBranches = !isLoadingBranches && branches && branches.length === 0;
|
||||
const allRepositories = repositories?.concat(searchedRepos || []);
|
||||
const repositoriesItems = allRepositories?.map((repo) => ({
|
||||
key: repo.id,
|
||||
label: decodeURIComponent(repo.full_name),
|
||||
}));
|
||||
|
||||
const handleProviderSelection = (provider: Provider | null) => {
|
||||
setSelectedProvider(provider);
|
||||
setSelectedRepository(null); // Reset repository selection when provider changes
|
||||
setSelectedBranch(null); // Reset branch selection when provider changes
|
||||
onRepoSelection(null); // Reset parent component's selected repo
|
||||
const branchesItems = branches?.map((branch) => ({
|
||||
key: branch.name,
|
||||
label: branch.name,
|
||||
}));
|
||||
|
||||
const handleRepoSelection = (key: React.Key | null) => {
|
||||
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
|
||||
if (selectedRepo) onRepoSelection(selectedRepo);
|
||||
setSelectedRepository(selectedRepo || null);
|
||||
setSelectedBranch(null); // Reset branch selection when repo changes
|
||||
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
|
||||
};
|
||||
|
||||
const handleBranchSelection = (branchName: string | null) => {
|
||||
const selectedBranchObj = branches?.find(
|
||||
(branch) => branch.name === branchName,
|
||||
);
|
||||
if (selectedBranchObj) {
|
||||
setSelectedBranch(selectedBranchObj);
|
||||
const handleBranchSelection = (key: React.Key | null) => {
|
||||
const selectedBranchObj = branches?.find((branch) => branch.name === key);
|
||||
setSelectedBranch(selectedBranchObj || null);
|
||||
// Reset the manually cleared flag when a branch is explicitly selected
|
||||
branchManuallyClearedRef.current = false;
|
||||
};
|
||||
|
||||
const handleRepoInputChange = (value: string) => {
|
||||
if (value === "") {
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
onRepoSelection(null);
|
||||
} else if (value.startsWith("https://")) {
|
||||
const repoName = sanitizeQuery(value);
|
||||
setSearchQuery(repoName);
|
||||
}
|
||||
};
|
||||
|
||||
// Render the provider dropdown
|
||||
const renderProviderSelector = () => {
|
||||
// Only render if there are multiple providers
|
||||
if (providers.length <= 1) {
|
||||
return null;
|
||||
const handleBranchInputChange = (value: string) => {
|
||||
// Clear the selected branch if the input is empty or contains only whitespace
|
||||
// This fixes the issue where users can't delete the entire default branch name
|
||||
if (value === "" || value.trim() === "") {
|
||||
setSelectedBranch(null);
|
||||
// Set the flag to indicate that the branch was manually cleared
|
||||
branchManuallyClearedRef.current = true;
|
||||
} else {
|
||||
// Reset the flag when the user starts typing again
|
||||
branchManuallyClearedRef.current = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<GitProviderDropdown
|
||||
providers={providers}
|
||||
value={selectedProvider}
|
||||
placeholder="Select Provider"
|
||||
className="max-w-[500px]"
|
||||
onChange={handleProviderSelection}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Effect to auto-select main/master branch when branches are loaded
|
||||
React.useEffect(() => {
|
||||
if (branches?.length) {
|
||||
// Look for main or master branch
|
||||
const defaultBranch = branches.find(
|
||||
(branch) => branch.name === "main" || branch.name === "master",
|
||||
);
|
||||
|
||||
// If found, select it, otherwise select the first branch
|
||||
setSelectedBranch(defaultBranch || branches[0]);
|
||||
}
|
||||
}, [branches]);
|
||||
|
||||
// Render the repository selector using our new component
|
||||
// Render the appropriate UI based on the loading/error state
|
||||
const renderRepositorySelector = () => {
|
||||
const handleRepoSelection = (repository?: GitRepository) => {
|
||||
if (repository) {
|
||||
onRepoSelection(repository);
|
||||
setSelectedRepository(repository);
|
||||
} else {
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
}
|
||||
};
|
||||
if (isLoadingRepositories) {
|
||||
return <RepositoryLoadingState />;
|
||||
}
|
||||
|
||||
if (isRepositoriesError) {
|
||||
return <RepositoryErrorState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GitRepositoryDropdown
|
||||
provider={selectedProvider || providers[0]}
|
||||
value={selectedRepository?.id || null}
|
||||
placeholder="Search repositories..."
|
||||
disabled={!selectedProvider}
|
||||
onChange={handleRepoSelection}
|
||||
className="max-w-[500px]"
|
||||
<RepositoryDropdown
|
||||
items={repositoriesItems || []}
|
||||
onSelectionChange={handleRepoSelection}
|
||||
onInputChange={handleRepoInputChange}
|
||||
defaultFilter={(textValue, inputValue) => {
|
||||
if (!inputValue) return true;
|
||||
|
||||
const repo = allRepositories?.find((r) => r.full_name === textValue);
|
||||
if (!repo) return false;
|
||||
|
||||
const sanitizedInput = sanitizeQuery(inputValue);
|
||||
return sanitizeQuery(textValue).includes(sanitizedInput);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the branch selector
|
||||
const renderBranchSelector = () => (
|
||||
<GitBranchDropdown
|
||||
repositoryName={selectedRepository?.full_name}
|
||||
value={selectedBranch?.name || null}
|
||||
placeholder="Select branch..."
|
||||
className="max-w-[500px]"
|
||||
disabled={!selectedRepository}
|
||||
onChange={handleBranchSelection}
|
||||
/>
|
||||
);
|
||||
// Render the appropriate UI for branch selector based on the loading/error state
|
||||
const renderBranchSelector = () => {
|
||||
if (!selectedRepository) {
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={[]}
|
||||
onSelectionChange={() => {}}
|
||||
onInputChange={() => {}}
|
||||
isDisabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingBranches) {
|
||||
return <BranchLoadingState />;
|
||||
}
|
||||
|
||||
if (isBranchesError) {
|
||||
return <BranchErrorState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={branchesItems || []}
|
||||
onSelectionChange={handleBranchSelection}
|
||||
onInputChange={handleBranchInputChange}
|
||||
isDisabled={false}
|
||||
selectedKey={selectedBranch?.name}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{renderProviderSelector()}
|
||||
{renderRepositorySelector()}
|
||||
|
||||
{renderBranchSelector()}
|
||||
|
||||
<BrandButton
|
||||
@@ -148,10 +205,9 @@ export function RepositorySelectionForm({
|
||||
type="button"
|
||||
isDisabled={
|
||||
!selectedRepository ||
|
||||
(!selectedBranch && !hasNoBranches) ||
|
||||
isLoadingBranches ||
|
||||
isCreatingConversation ||
|
||||
(providers.length > 1 && !selectedProvider)
|
||||
isLoadingRepositories ||
|
||||
isRepositoriesError
|
||||
}
|
||||
onClick={() =>
|
||||
createConversation(
|
||||
@@ -159,7 +215,7 @@ export function RepositorySelectionForm({
|
||||
repository: {
|
||||
name: selectedRepository?.full_name || "",
|
||||
gitProvider: selectedRepository?.git_provider || "github",
|
||||
branch: selectedBranch?.name || (hasNoBranches ? "" : "main"),
|
||||
branch: selectedBranch?.name || "main",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export { RepositoryDropdown } from "#/components/features/home/repository-selection/repository-dropdown";
|
||||
export { RepositoryLoadingState } from "#/components/features/home/repository-selection/repository-loading-state";
|
||||
export { RepositoryErrorState } from "#/components/features/home/repository-selection/repository-error-state";
|
||||
export { BranchDropdown } from "#/components/features/home/repository-selection/branch-dropdown";
|
||||
export { BranchLoadingState } from "#/components/features/home/repository-selection/branch-loading-state";
|
||||
export { BranchErrorState } from "#/components/features/home/repository-selection/branch-error-state";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export interface RepositoryDropdownProps {
|
||||
items: { key: React.Key; label: string }[];
|
||||
onSelectionChange: (key: React.Key | null) => void;
|
||||
onInputChange: (value: string) => void;
|
||||
defaultFilter?: (textValue: string, inputValue: string) => boolean;
|
||||
}
|
||||
|
||||
export function RepositoryDropdown({
|
||||
items,
|
||||
onSelectionChange,
|
||||
onInputChange,
|
||||
defaultFilter,
|
||||
}: RepositoryDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SettingsDropdownInput
|
||||
testId="repo-dropdown"
|
||||
name="repo-dropdown"
|
||||
placeholder={t(I18nKey.REPOSITORY$SELECT_REPO)}
|
||||
items={items}
|
||||
wrapperClassName="max-w-[500px]"
|
||||
onSelectionChange={onSelectionChange}
|
||||
onInputChange={onInputChange}
|
||||
defaultFilter={defaultFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function RepositoryErrorState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-testid="repo-dropdown-error"
|
||||
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500"
|
||||
>
|
||||
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@heroui/react";
|
||||
|
||||
export function RepositoryLoadingState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-testid="repo-dropdown-loading"
|
||||
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm"
|
||||
>
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm">{t("HOME$LOADING_REPOSITORIES")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { Spinner } from "@heroui/react";
|
||||
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
|
||||
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import {
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
@@ -23,21 +22,15 @@ export function MicroagentManagementSidebar({
|
||||
}: MicroagentManagementSidebarProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { providers } = useUserProviders();
|
||||
const selectedProvider = providers.length > 0 ? providers[0] : null;
|
||||
const { data: repositories, isLoading } =
|
||||
useUserRepositories(selectedProvider);
|
||||
const { data: repositories, isLoading } = useUserRepositories();
|
||||
|
||||
useEffect(() => {
|
||||
if (repositories?.pages) {
|
||||
if (repositories) {
|
||||
const personalRepos: GitRepository[] = [];
|
||||
const organizationRepos: GitRepository[] = [];
|
||||
const otherRepos: GitRepository[] = [];
|
||||
|
||||
// Flatten all pages to get all repositories
|
||||
const allRepositories = repositories.pages.flatMap((page) => page.data);
|
||||
|
||||
allRepositories.forEach((repo: GitRepository) => {
|
||||
repositories.forEach((repo: GitRepository) => {
|
||||
const hasOpenHandsSuffix = repo.full_name.endsWith("/.openhands");
|
||||
|
||||
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Autocomplete, AutocompleteItem } from "@heroui/react";
|
||||
import React, { ReactNode } from "react";
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OptionalTag } from "./optional-tag";
|
||||
import { cn } from "#/utils/utils";
|
||||
@@ -44,7 +44,6 @@ export function SettingsDropdownInput({
|
||||
defaultFilter,
|
||||
}: SettingsDropdownInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
|
||||
{label && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
|
||||
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
|
||||
interface UserActionsProps {
|
||||
onLogout: () => void;
|
||||
@@ -10,12 +10,10 @@ interface UserActionsProps {
|
||||
}
|
||||
|
||||
export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
|
||||
React.useState(false);
|
||||
|
||||
// Use the shared hook to determine if user actions should be shown
|
||||
const shouldShowUserActions = useShouldShowUserFeatures();
|
||||
|
||||
const toggleAccountMenu = () => {
|
||||
// Always toggle the menu, even if user is undefined
|
||||
setAccountContextMenuIsVisible((prev) => !prev);
|
||||
@@ -30,8 +28,8 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
|
||||
closeAccountMenu();
|
||||
};
|
||||
|
||||
// Show the menu based on the new logic
|
||||
const showMenu = accountContextMenuIsVisible && shouldShowUserActions;
|
||||
// Always show the menu for authenticated users, even without user data
|
||||
const showMenu = accountContextMenuIsVisible && isAuthed;
|
||||
|
||||
return (
|
||||
<div data-testid="user-actions" className="w-8 h-8 relative cursor-pointer">
|
||||
|
||||
@@ -15,14 +15,12 @@ import { Provider } from "#/types/settings";
|
||||
interface AuthModalProps {
|
||||
githubAuthUrl: string | null;
|
||||
appMode?: GetConfigResponse["APP_MODE"] | null;
|
||||
authUrl?: GetConfigResponse["AUTH_URL"];
|
||||
providersConfigured?: Provider[];
|
||||
}
|
||||
|
||||
export function AuthModal({
|
||||
githubAuthUrl,
|
||||
appMode,
|
||||
authUrl,
|
||||
providersConfigured,
|
||||
}: AuthModalProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -30,19 +28,16 @@ export function AuthModal({
|
||||
const gitlabAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
identityProvider: "gitlab",
|
||||
authUrl,
|
||||
});
|
||||
|
||||
const bitbucketAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
identityProvider: "bitbucket",
|
||||
authUrl,
|
||||
});
|
||||
|
||||
const enterpriseSsoUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
identityProvider: "enterprise_sso",
|
||||
authUrl,
|
||||
});
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
|
||||
@@ -67,9 +67,9 @@ prepareApp().then(() =>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydratedRouter />
|
||||
<PosthogInit />
|
||||
<div id="modal-portal-exit" />
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
<div id="modal-portal-exit" />
|
||||
</StrictMode>,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -27,10 +27,6 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
search_api_key: settings.SEARCH_API_KEY?.trim() || "",
|
||||
max_budget_per_task: settings.MAX_BUDGET_PER_TASK,
|
||||
git_user_name:
|
||||
settings.GIT_USER_NAME?.trim() || DEFAULT_SETTINGS.GIT_USER_NAME,
|
||||
git_user_email:
|
||||
settings.GIT_USER_EMAIL?.trim() || DEFAULT_SETTINGS.GIT_USER_EMAIL,
|
||||
};
|
||||
|
||||
await OpenHands.saveSettings(apiSettings);
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { shouldUseInstallationRepos } from "#/utils/utils";
|
||||
|
||||
export const useAppInstallations = (selectedProvider: Provider | null) => {
|
||||
const { data: config } = useConfig();
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["installations", providers || [], selectedProvider],
|
||||
queryFn: () => OpenHands.getUserInstallationIds(selectedProvider!),
|
||||
enabled:
|
||||
userIsAuthenticated &&
|
||||
!!selectedProvider &&
|
||||
shouldUseInstallationRepos(selectedProvider, config?.APP_MODE),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
import { useAppInstallations } from "./use-app-installations";
|
||||
import { GitRepository } from "../../types/git";
|
||||
import { Provider } from "../../types/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { shouldUseInstallationRepos } from "#/utils/utils";
|
||||
|
||||
interface UseGitRepositoriesOptions {
|
||||
provider: Provider | null;
|
||||
pageSize?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UserRepositoriesResponse {
|
||||
data: GitRepository[];
|
||||
nextPage: number | null;
|
||||
}
|
||||
|
||||
interface InstallationRepositoriesResponse {
|
||||
data: GitRepository[];
|
||||
nextPage: number | null;
|
||||
installationIndex: number | null;
|
||||
}
|
||||
|
||||
export function useGitRepositories(options: UseGitRepositoriesOptions) {
|
||||
const { provider, pageSize = 30, enabled = true } = options;
|
||||
const { providers } = useUserProviders();
|
||||
const { data: config } = useConfig();
|
||||
const { data: installations } = useAppInstallations(provider);
|
||||
|
||||
const useInstallationRepos = provider
|
||||
? shouldUseInstallationRepos(provider, config?.APP_MODE)
|
||||
: false;
|
||||
|
||||
const repos = useInfiniteQuery<
|
||||
UserRepositoriesResponse | InstallationRepositoriesResponse
|
||||
>({
|
||||
queryKey: [
|
||||
"repositories",
|
||||
providers || [],
|
||||
provider,
|
||||
useInstallationRepos,
|
||||
pageSize,
|
||||
...(useInstallationRepos ? [installations || []] : []),
|
||||
],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
if (!provider) {
|
||||
throw new Error("Provider is required");
|
||||
}
|
||||
|
||||
if (useInstallationRepos) {
|
||||
const { repoPage, installationIndex } = pageParam as {
|
||||
installationIndex: number | null;
|
||||
repoPage: number | null;
|
||||
};
|
||||
|
||||
if (!installations) {
|
||||
throw new Error("Missing installation list");
|
||||
}
|
||||
|
||||
return OpenHands.retrieveInstallationRepositories(
|
||||
provider,
|
||||
installationIndex || 0,
|
||||
installations,
|
||||
repoPage || 1,
|
||||
pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
return OpenHands.retrieveUserGitRepositories(
|
||||
provider,
|
||||
pageParam as number,
|
||||
pageSize,
|
||||
);
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (useInstallationRepos) {
|
||||
const installationPage = lastPage as InstallationRepositoriesResponse;
|
||||
if (installationPage.nextPage) {
|
||||
return {
|
||||
installationIndex: installationPage.installationIndex,
|
||||
repoPage: installationPage.nextPage,
|
||||
};
|
||||
}
|
||||
|
||||
if (installationPage.installationIndex !== null) {
|
||||
return {
|
||||
installationIndex: installationPage.installationIndex,
|
||||
repoPage: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const userPage = lastPage as UserRepositoriesResponse;
|
||||
return userPage.nextPage;
|
||||
},
|
||||
initialPageParam: useInstallationRepos
|
||||
? { installationIndex: 0, repoPage: 1 }
|
||||
: 1,
|
||||
enabled:
|
||||
enabled &&
|
||||
(providers || []).length > 0 &&
|
||||
!!provider &&
|
||||
(!useInstallationRepos ||
|
||||
(Array.isArray(installations) && installations.length > 0)),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const onLoadMore = () => {
|
||||
if (repos.hasNextPage && !repos.isFetchingNextPage) {
|
||||
repos.fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
data: repos.data,
|
||||
isLoading: repos.isLoading,
|
||||
isError: repos.isError,
|
||||
hasNextPage: repos.hasNextPage,
|
||||
isFetchingNextPage: repos.isFetchingNextPage,
|
||||
fetchNextPage: repos.fetchNextPage,
|
||||
onLoadMore,
|
||||
};
|
||||
}
|
||||
@@ -3,18 +3,16 @@ import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
|
||||
export const useGitUser = () => {
|
||||
const { data: config } = useConfig();
|
||||
|
||||
// Use the shared hook to determine if we should fetch user data
|
||||
const shouldFetchUser = useShouldShowUserFeatures();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
|
||||
const user = useQuery({
|
||||
queryKey: ["user"],
|
||||
queryFn: OpenHands.getGitUser,
|
||||
enabled: shouldFetchUser,
|
||||
enabled: !!config?.APP_MODE && isAuthed, // Enable regardless of providers
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useAppInstallations } from "./use-app-installations";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
import { Provider } from "#/types/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { shouldUseInstallationRepos } from "#/utils/utils";
|
||||
|
||||
export const useInstallationRepositories = (
|
||||
selectedProvider: Provider | null,
|
||||
) => {
|
||||
const { providers } = useUserProviders();
|
||||
const { data: config } = useConfig();
|
||||
const { data: installations } = useAppInstallations(selectedProvider);
|
||||
|
||||
const repos = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"repositories",
|
||||
providers || [],
|
||||
selectedProvider,
|
||||
installations || [],
|
||||
],
|
||||
queryFn: async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: { installationIndex: number | null; repoPage: number | null };
|
||||
}) => {
|
||||
const { repoPage, installationIndex } = pageParam;
|
||||
|
||||
if (!installations) {
|
||||
throw new Error("Missing installation list");
|
||||
}
|
||||
|
||||
return OpenHands.retrieveInstallationRepositories(
|
||||
selectedProvider!,
|
||||
installationIndex || 0,
|
||||
installations,
|
||||
repoPage || 1,
|
||||
30,
|
||||
);
|
||||
},
|
||||
initialPageParam: { installationIndex: 0, repoPage: 1 },
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.nextPage) {
|
||||
return {
|
||||
installationIndex: lastPage.installationIndex,
|
||||
repoPage: lastPage.nextPage,
|
||||
};
|
||||
}
|
||||
|
||||
if (lastPage.installationIndex !== null) {
|
||||
return { installationIndex: lastPage.installationIndex, repoPage: 1 };
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
enabled:
|
||||
(providers || []).length > 0 &&
|
||||
!!selectedProvider &&
|
||||
shouldUseInstallationRepos(selectedProvider, config?.APP_MODE) &&
|
||||
Array.isArray(installations) &&
|
||||
installations.length > 0,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
const onLoadMore = () => {
|
||||
if (repos.hasNextPage && !repos.isFetchingNextPage) {
|
||||
repos.fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
// Return the query result with the scroll ref
|
||||
return {
|
||||
data: repos.data,
|
||||
isLoading: repos.isLoading,
|
||||
isError: repos.isError,
|
||||
hasNextPage: repos.hasNextPage,
|
||||
isFetchingNextPage: repos.isFetchingNextPage,
|
||||
onLoadMore,
|
||||
};
|
||||
};
|
||||
@@ -1,16 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export function useSearchRepositories(
|
||||
query: string,
|
||||
selectedProvider?: Provider | null,
|
||||
) {
|
||||
export function useSearchRepositories(query: string) {
|
||||
return useQuery({
|
||||
queryKey: ["repositories", "search", query, selectedProvider],
|
||||
queryFn: () =>
|
||||
OpenHands.searchGitRepositories(query, 3, selectedProvider || undefined),
|
||||
enabled: !!query && !!selectedProvider,
|
||||
queryKey: ["repositories", query],
|
||||
queryFn: () => OpenHands.searchGitRepositories(query, 3),
|
||||
enabled: !!query,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
@@ -31,9 +31,6 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
EMAIL: apiSettings.email || "",
|
||||
EMAIL_VERIFIED: apiSettings.email_verified,
|
||||
MCP_CONFIG: apiSettings.mcp_config,
|
||||
GIT_USER_NAME: apiSettings.git_user_name || DEFAULT_SETTINGS.GIT_USER_NAME,
|
||||
GIT_USER_EMAIL:
|
||||
apiSettings.git_user_email || DEFAULT_SETTINGS.GIT_USER_EMAIL,
|
||||
IS_NEW_USER: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,41 +1,10 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { shouldUseInstallationRepos } from "#/utils/utils";
|
||||
|
||||
export const useUserRepositories = (selectedProvider: Provider | null) => {
|
||||
const { providers } = useUserProviders();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const repos = useInfiniteQuery({
|
||||
queryKey: ["repositories", providers || [], selectedProvider],
|
||||
queryFn: async ({ pageParam }) =>
|
||||
OpenHands.retrieveUserGitRepositories(selectedProvider!, pageParam, 30),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||||
enabled:
|
||||
(providers || []).length > 0 &&
|
||||
!!selectedProvider &&
|
||||
!shouldUseInstallationRepos(selectedProvider, config?.APP_MODE),
|
||||
export const useUserRepositories = () =>
|
||||
useQuery({
|
||||
queryKey: ["repositories"],
|
||||
queryFn: OpenHands.retrieveUserGitRepositories,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
const onLoadMore = () => {
|
||||
if (repos.hasNextPage && !repos.isFetchingNextPage) {
|
||||
repos.fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
// Return the query result with the scroll ref
|
||||
return {
|
||||
data: repos.data,
|
||||
isLoading: repos.isLoading,
|
||||
isError: repos.isError,
|
||||
hasNextPage: repos.hasNextPage,
|
||||
isFetchingNextPage: repos.isFetchingNextPage,
|
||||
onLoadMore,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
interface UseAuthUrlConfig {
|
||||
appMode: GetConfigResponse["APP_MODE"] | null;
|
||||
identityProvider: string;
|
||||
authUrl?: GetConfigResponse["AUTH_URL"];
|
||||
}
|
||||
|
||||
export const useAuthUrl = (config: UseAuthUrlConfig) => {
|
||||
@@ -12,7 +11,6 @@ export const useAuthUrl = (config: UseAuthUrlConfig) => {
|
||||
return generateAuthUrl(
|
||||
config.identityProvider,
|
||||
new URL(window.location.href),
|
||||
config.authUrl,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,25 +19,21 @@ export const useAutoLogin = () => {
|
||||
const githubAuthUrl = useAuthUrl({
|
||||
appMode: config?.APP_MODE || null,
|
||||
identityProvider: "github",
|
||||
authUrl: config?.AUTH_URL,
|
||||
});
|
||||
|
||||
const gitlabAuthUrl = useAuthUrl({
|
||||
appMode: config?.APP_MODE || null,
|
||||
identityProvider: "gitlab",
|
||||
authUrl: config?.AUTH_URL,
|
||||
});
|
||||
|
||||
const bitbucketAuthUrl = useAuthUrl({
|
||||
appMode: config?.APP_MODE || null,
|
||||
identityProvider: "bitbucket",
|
||||
authUrl: config?.AUTH_URL,
|
||||
});
|
||||
|
||||
const enterpriseSsoUrl = useAuthUrl({
|
||||
appMode: config?.APP_MODE || null,
|
||||
identityProvider: "enterprise_sso",
|
||||
authUrl: config?.AUTH_URL,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,12 +4,10 @@ import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
interface UseGitHubAuthUrlConfig {
|
||||
appMode: GetConfigResponse["APP_MODE"] | null;
|
||||
gitHubClientId: GetConfigResponse["GITHUB_CLIENT_ID"] | null;
|
||||
authUrl?: GetConfigResponse["AUTH_URL"];
|
||||
}
|
||||
|
||||
export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) =>
|
||||
useAuthUrl({
|
||||
appMode: config.appMode,
|
||||
identityProvider: "github",
|
||||
authUrl: config.authUrl,
|
||||
});
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from "react";
|
||||
import { useConfig } from "./query/use-config";
|
||||
import { useIsAuthed } from "./query/use-is-authed";
|
||||
import { useUserProviders } from "./use-user-providers";
|
||||
|
||||
/**
|
||||
* Hook to determine if user-related features should be shown or enabled
|
||||
* based on authentication status and provider configuration.
|
||||
*
|
||||
* @returns boolean indicating if user features should be shown
|
||||
*/
|
||||
export const useShouldShowUserFeatures = (): boolean => {
|
||||
const { data: config } = useConfig();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (!config?.APP_MODE || !isAuthed) return false;
|
||||
|
||||
// In OSS mode, only show user features if Git providers are configured
|
||||
if (config.APP_MODE === "oss") {
|
||||
return providers.length > 0;
|
||||
}
|
||||
|
||||
// In non-OSS modes (saas), always show user features when authenticated
|
||||
return true;
|
||||
}, [config?.APP_MODE, isAuthed, providers.length]);
|
||||
};
|
||||
@@ -44,6 +44,7 @@ export enum I18nKey {
|
||||
SECRETS$SECRET_VALUE_REQUIRED = "SECRETS$SECRET_VALUE_REQUIRED",
|
||||
SECRETS$ADD_SECRET = "SECRETS$ADD_SECRET",
|
||||
SECRETS$EDIT_SECRET = "SECRETS$EDIT_SECRET",
|
||||
SECRETS$NO_SECRETS_FOUND = "SECRETS$NO_SECRETS_FOUND",
|
||||
SECRETS$ADD_NEW_SECRET = "SECRETS$ADD_NEW_SECRET",
|
||||
SECRETS$CONFIRM_DELETE_KEY = "SECRETS$CONFIRM_DELETE_KEY",
|
||||
SETTINGS$MCP_TITLE = "SETTINGS$MCP_TITLE",
|
||||
@@ -77,7 +78,6 @@ export enum I18nKey {
|
||||
HOME$OPENHANDS_DESCRIPTION = "HOME$OPENHANDS_DESCRIPTION",
|
||||
HOME$NOT_SURE_HOW_TO_START = "HOME$NOT_SURE_HOW_TO_START",
|
||||
HOME$CONNECT_TO_REPOSITORY = "HOME$CONNECT_TO_REPOSITORY",
|
||||
HOME$CONNECT_TO_REPOSITORY_TOOLTIP = "HOME$CONNECT_TO_REPOSITORY_TOOLTIP",
|
||||
HOME$LOADING = "HOME$LOADING",
|
||||
HOME$LOADING_REPOSITORIES = "HOME$LOADING_REPOSITORIES",
|
||||
HOME$FAILED_TO_LOAD_REPOSITORIES = "HOME$FAILED_TO_LOAD_REPOSITORIES",
|
||||
@@ -236,7 +236,6 @@ export enum I18nKey {
|
||||
SESSION$SESSION_HANDLING_ERROR_MESSAGE = "SESSION$SESSION_HANDLING_ERROR_MESSAGE",
|
||||
SESSION$SESSION_CONNECTION_ERROR_MESSAGE = "SESSION$SESSION_CONNECTION_ERROR_MESSAGE",
|
||||
SESSION$SOCKET_NOT_INITIALIZED_ERROR_MESSAGE = "SESSION$SOCKET_NOT_INITIALIZED_ERROR_MESSAGE",
|
||||
SESSION$TIMEOUT_MESSAGE = "SESSION$TIMEOUT_MESSAGE",
|
||||
EXPLORER$UPLOAD_ERROR_MESSAGE = "EXPLORER$UPLOAD_ERROR_MESSAGE",
|
||||
EXPLORER$LABEL_DROP_FILES = "EXPLORER$LABEL_DROP_FILES",
|
||||
EXPLORER$UPLOAD_SUCCESS_MESSAGE = "EXPLORER$UPLOAD_SUCCESS_MESSAGE",
|
||||
@@ -584,8 +583,7 @@ export enum I18nKey {
|
||||
BITBUCKET$TOKEN_LINK_TEXT = "BITBUCKET$TOKEN_LINK_TEXT",
|
||||
BITBUCKET$INSTRUCTIONS_LINK_TEXT = "BITBUCKET$INSTRUCTIONS_LINK_TEXT",
|
||||
GITLAB$OR_SEE = "GITLAB$OR_SEE",
|
||||
AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_STOPPED = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_STOPPED",
|
||||
AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_ERROR = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_ERROR",
|
||||
AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED",
|
||||
DIFF_VIEWER$LOADING = "DIFF_VIEWER$LOADING",
|
||||
DIFF_VIEWER$GETTING_LATEST_CHANGES = "DIFF_VIEWER$GETTING_LATEST_CHANGES",
|
||||
DIFF_VIEWER$NOT_A_GIT_REPO = "DIFF_VIEWER$NOT_A_GIT_REPO",
|
||||
@@ -739,8 +737,6 @@ export enum I18nKey {
|
||||
MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO = "MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO",
|
||||
MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO = "MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO",
|
||||
MICROAGENT_MANAGEMENT$UPDATE_MICROAGENT_MODAL_DESCRIPTION = "MICROAGENT_MANAGEMENT$UPDATE_MICROAGENT_MODAL_DESCRIPTION",
|
||||
SETTINGS$GIT_USERNAME = "SETTINGS$GIT_USERNAME",
|
||||
SETTINGS$GIT_EMAIL = "SETTINGS$GIT_EMAIL",
|
||||
PROJECT_MANAGEMENT$TITLE = "PROJECT_MANAGEMENT$TITLE",
|
||||
PROJECT_MANAGEMENT$VALIDATE_INTEGRATION_ERROR = "PROJECT_MANAGEMENT$VALIDATE_INTEGRATION_ERROR",
|
||||
PROJECT_MANAGEMENT$LINK_BUTTON_LABEL = "PROJECT_MANAGEMENT$LINK_BUTTON_LABEL",
|
||||
|
||||
@@ -703,6 +703,22 @@
|
||||
"de": "Geheimnis bearbeiten",
|
||||
"uk": "Редагувати секрет"
|
||||
},
|
||||
"SECRETS$NO_SECRETS_FOUND": {
|
||||
"en": "No secrets found",
|
||||
"ja": "シークレットが見つかりません",
|
||||
"zh-CN": "未找到密钥",
|
||||
"zh-TW": "未找到密鑰",
|
||||
"ko-KR": "비밀을 찾을 수 없습니다",
|
||||
"no": "Ingen hemmeligheter funnet",
|
||||
"it": "Nessun segreto trovato",
|
||||
"pt": "Nenhum segredo encontrado",
|
||||
"es": "No se encontraron secretos",
|
||||
"ar": "لم يتم العثور على أسرار",
|
||||
"fr": "Aucun secret trouvé",
|
||||
"tr": "Gizli bulunamadı",
|
||||
"de": "Keine Geheimnisse gefunden",
|
||||
"uk": "Секретів не знайдено"
|
||||
},
|
||||
"SECRETS$ADD_NEW_SECRET": {
|
||||
"en": "Add a new secret",
|
||||
"ja": "新しいシークレットを追加",
|
||||
@@ -1231,22 +1247,6 @@
|
||||
"de": "Mit einem Repository verbinden",
|
||||
"uk": "Підключіть до репозиторій"
|
||||
},
|
||||
"HOME$CONNECT_TO_REPOSITORY_TOOLTIP": {
|
||||
"en": "You can enter a public GitHub URL if you'd like to work from a public repo instead",
|
||||
"ja": "代わりにパブリックリポジトリで作業したい場合は、パブリックGitHub URLを入力できます",
|
||||
"zh-CN": "如果您想从公共仓库工作,可以输入公共GitHub URL",
|
||||
"zh-TW": "如果您想從公共儲存庫工作,可以輸入公共GitHub URL",
|
||||
"ko-KR": "대신 공개 저장소에서 작업하고 싶다면 공개 GitHub URL을 입력할 수 있습니다",
|
||||
"no": "Du kan angi en offentlig GitHub URL hvis du vil jobbe fra et offentlig repo i stedet",
|
||||
"it": "Puoi inserire un URL GitHub pubblico se preferisci lavorare da un repository pubblico",
|
||||
"pt": "Você pode inserir um URL GitHub público se quiser trabalhar de um repositório público",
|
||||
"es": "Puede ingresar una URL de GitHub pública si desea trabajar desde un repositorio público",
|
||||
"ar": "يمكنك إدخال رابط GitHub عام إذا كنت تريد العمل من مستودع عام بدلاً من ذلك",
|
||||
"fr": "Vous pouvez saisir une URL GitHub publique si vous souhaitez travailler à partir d'un dépôt public",
|
||||
"tr": "Bunun yerine genel bir repo'dan çalışmak istiyorsanız genel bir GitHub URL'si girebilirsiniz",
|
||||
"de": "Sie können eine öffentliche GitHub-URL eingeben, wenn Sie stattdessen von einem öffentlichen Repository arbeiten möchten",
|
||||
"uk": "Ви можете ввести публічну GitHub URL, якщо хочете працювати з публічного репозиторію"
|
||||
},
|
||||
"HOME$LOADING": {
|
||||
"en": "Loading...",
|
||||
"ja": "読み込み中...",
|
||||
@@ -3775,22 +3775,6 @@
|
||||
"ja": "ソケットが初期化されていません",
|
||||
"uk": "Сокет не ініціалізовано"
|
||||
},
|
||||
"SESSION$TIMEOUT_MESSAGE": {
|
||||
"en": "Session Timeout!",
|
||||
"zh-CN": "会话超时!",
|
||||
"de": "Sitzung abgelaufen!",
|
||||
"zh-TW": "會話超時!",
|
||||
"es": "¡Tiempo de sesión agotado!",
|
||||
"fr": "Expiration de la session !",
|
||||
"it": "Timeout della sessione!",
|
||||
"pt": "Tempo limite da sessão!",
|
||||
"ko-KR": "세션 시간 초과!",
|
||||
"ar": "انتهت مهلة الجلسة!",
|
||||
"tr": "Oturum Zaman Aşımı!",
|
||||
"no": "Økten har tidsavbrutt!",
|
||||
"ja": "セッションタイムアウト!",
|
||||
"uk": "Час сеансу минув!"
|
||||
},
|
||||
"EXPLORER$UPLOAD_ERROR_MESSAGE": {
|
||||
"en": "Error uploading file",
|
||||
"zh-CN": "上传时出错",
|
||||
@@ -9343,37 +9327,21 @@
|
||||
"de": "oder siehe",
|
||||
"uk": "або перегляньте"
|
||||
},
|
||||
"AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_STOPPED": {
|
||||
"en": "Stop button pressed. The action has not been executed.",
|
||||
"ja": "停止ボタンが押されました。アクションは実行されていません。",
|
||||
"zh-CN": "按下了停止按钮。操作尚未执行。",
|
||||
"zh-TW": "按下了停止按鈕。操作尚未執行。",
|
||||
"ko-KR": "중지 버튼이 눌렸습니다. 작업이 실행되지 않았습니다.",
|
||||
"no": "Stoppknappen ble trykket. Handlingen har ikke blitt utført.",
|
||||
"it": "Pulsante di arresto premuto. L'azione non è stata eseguita.",
|
||||
"pt": "Botão de parar pressionado. A ação não foi executada.",
|
||||
"es": "Botón de detener presionado. La acción no se ha ejecutado.",
|
||||
"ar": "تم الضغط على زر التوقف. لم يتم تنفيذ الإجراء.",
|
||||
"fr": "Bouton d'arrêt appuyé. L'action n'a pas été exécutée.",
|
||||
"tr": "Durdurma düğmesine basıldı. Eylem yürütülmedi.",
|
||||
"de": "Stopp-Taste gedrückt. Die Aktion wurde nicht ausgeführt.",
|
||||
"uk": "Натиснуто кнопку зупинки. Дію не виконано."
|
||||
},
|
||||
"AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_ERROR": {
|
||||
"en": "The action has not been executed due to a runtime error. The runtime system may have crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.",
|
||||
"ja": "ランタイムエラーによりアクションは実行されていません。リソース制約によりランタイムシステムがクラッシュして再起動した可能性があります。以前に確立されたシステム状態、依存関係、または環境変数は失われている可能性があります。",
|
||||
"zh-CN": "由于运行时错误,该操作尚未执行。运行时系统可能由于资源限制而崩溃并重新启动。任何先前建立的系统状态、依赖项或环境变量可能已丢失。",
|
||||
"zh-TW": "由於運行時錯誤,該操作尚未執行。運行時系統可能由於資源限制而崩潰並重新啟動。任何先前建立的系統狀態、依賴項或環境變數可能已丟失。",
|
||||
"ko-KR": "런타임 오류로 인해 작업이 실행되지 않았습니다. 리소스 제약으로 인해 런타임 시스템이 충돌하고 재시작되었을 수 있습니다. 이전에 설정된 시스템 상태, 종속성 또는 환경 변수가 손실되었을 수 있습니다.",
|
||||
"no": "Handlingen har ikke blitt utført på grunn av en kjøretidsfeil. Kjøretidssystemet kan ha krasjet og startet på nytt på grunn av ressursbegrensninger. Enhver tidligere etablert systemtilstand, avhengigheter eller miljøvariabler kan ha gått tapt.",
|
||||
"it": "L'azione non è stata eseguita a causa di un errore di runtime. Il sistema di runtime potrebbe essere andato in crash e riavviato a causa di vincoli di risorse. Qualsiasi stato di sistema, dipendenza o variabile d'ambiente precedentemente stabilito potrebbe essere andato perso.",
|
||||
"pt": "A ação não foi executada devido a um erro de tempo de execução. O sistema de tempo de execução pode ter travado e reiniciado devido a restrições de recursos. Qualquer estado do sistema, dependências ou variáveis de ambiente estabelecidos anteriormente podem ter sido perdidos.",
|
||||
"es": "La acción no se ha ejecutado debido a un error de tiempo de ejecución. El sistema de tiempo de ejecución puede haberse bloqueado y reiniciado debido a restricciones de recursos. Cualquier estado del sistema, dependencias o variables de entorno establecidos previamente pueden haberse perdido.",
|
||||
"ar": "لم يتم تنفيذ الإجراء بسبب خطأ في وقت التشغيل. قد يكون نظام وقت التشغيل قد تعطل وأعيد تشغيله بسبب قيود الموارد. قد تكون أي حالة نظام أو تبعيات أو متغيرات بيئية تم إنشاؤها مسبقًا قد فُقدت.",
|
||||
"fr": "L'action n'a pas été exécutée en raison d'une erreur d'exécution. Le système d'exécution peut s'être planté et avoir redémarré en raison de contraintes de ressources. Tout état du système, dépendances ou variables d'environnement précédemment établis peuvent avoir été perdus.",
|
||||
"tr": "Çalışma zamanı hatası nedeniyle eylem yürütülmedi. Çalışma zamanı sistemi kaynak kısıtlamaları nedeniyle çökmüş ve yeniden başlamış olabilir. Daha önce kurulmuş olan herhangi bir sistem durumu, bağımlılıklar veya ortam değişkenleri kaybolmuş olabilir.",
|
||||
"de": "Die Aktion wurde aufgrund eines Laufzeitfehlers nicht ausgeführt. Das Laufzeitsystem ist möglicherweise aufgrund von Ressourcenbeschränkungen abgestürzt und neu gestartet worden. Alle zuvor eingerichteten Systemzustände, Abhängigkeiten oder Umgebungsvariablen sind möglicherweise verloren gegangen.",
|
||||
"uk": "Дію не виконано через помилку виконання. Система виконання могла зазнати збою та перезапуститися через обмеження ресурсів. Можливо, було втрачено будь-який раніше встановлений стан системи, залежності або змінні середовища."
|
||||
"AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED": {
|
||||
"en": "The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.",
|
||||
"ja": "アクションは実行されていません。これはユーザーが停止ボタンを押したか、リソース制約によりランタイムシステムがクラッシュして再起動したことが原因かもしれません。以前に確立されたシステム状態、依存関係、または環境変数は失われている可能性があります。",
|
||||
"zh-CN": "该操作尚未执行。这可能是因为用户按下了停止按钮,或者因为运行时系统由于资源限制而崩溃并重新启动。任何先前建立的系统状态、依赖项或环境变量可能已丢失。",
|
||||
"zh-TW": "該操作尚未執行。這可能是因為用戶按下了停止按鈕,或者因為運行時系統由於資源限制而崩潰並重新啟動。任何先前建立的系統狀態、依賴項或環境變數可能已丟失。",
|
||||
"ko-KR": "작업이 실행되지 않았습니다. 이는 사용자가 중지 버튼을 눌렀거나 리소스 제약으로 인해 런타임 시스템이 충돌하고 재시작되었기 때문일 수 있습니다. 이전에 설정된 시스템 상태, 종속성 또는 환경 변수가 손실되었을 수 있습니다.",
|
||||
"no": "Handlingen har ikke blitt utført. Dette kan ha skjedd fordi brukeren trykket på stoppknappen, eller fordi kjøretidssystemet krasjet og startet på nytt på grunn av ressursbegrensninger. Enhver tidligere etablert systemtilstand, avhengigheter eller miljøvariabler kan ha gått tapt.",
|
||||
"it": "L'azione non è stata eseguita. Ciò potrebbe essere accaduto perché l'utente ha premuto il pulsante di arresto, o perché il sistema di runtime si è arrestato in modo anomalo e riavviato a causa di vincoli di risorse. Qualsiasi stato di sistema, dipendenza o variabile d'ambiente precedentemente stabilito potrebbe essere andato perso.",
|
||||
"pt": "A ação não foi executada. Isso pode ter ocorrido porque o usuário pressionou o botão de parar, ou porque o sistema de tempo de execução travou e reiniciou devido a restrições de recursos. Qualquer estado do sistema, dependências ou variáveis de ambiente estabelecidos anteriormente podem ter sido perdidos.",
|
||||
"es": "La acción no se ha ejecutado. Esto puede haber ocurrido porque el usuario presionó el botón de detener, o porque el sistema de tiempo de ejecución se bloqueó y reinició debido a restricciones de recursos. Cualquier estado del sistema, dependencias o variables de entorno establecidos previamente pueden haberse perdido.",
|
||||
"ar": "لم يتم تنفيذ الإجراء. قد يكون هذا حدث لأن المستخدم ضغط على زر التوقف، أو لأن نظام التشغيل تعطل وأعيد تشغيله بسبب قيود الموارد. قد تكون أي حالة نظام أو تبعيات أو متغيرات بيئية تم إنشاؤها مسبقًا قد فُقدت.",
|
||||
"fr": "L'action n'a pas été exécutée. Cela peut s'être produit parce que l'utilisateur a appuyé sur le bouton d'arrêt, ou parce que le système d'exécution s'est planté et a redémarré en raison de contraintes de ressources. Tout état du système, dépendances ou variables d'environnement précédemment établis peuvent avoir été perdus.",
|
||||
"tr": "Eylem yürütülmedi. Bu, kullanıcının durdurma düğmesine basması veya çalışma zamanı sisteminin kaynak kısıtlamaları nedeniyle çökmesi ve yeniden başlaması nedeniyle olmuş olabilir. Daha önce kurulmuş olan herhangi bir sistem durumu, bağımlılıklar veya ortam değişkenleri kaybolmuş olabilir.",
|
||||
"de": "Die Aktion wurde nicht ausgeführt. Dies kann passiert sein, weil der Benutzer die Stopp-Taste gedrückt hat oder weil das Laufzeitsystem aufgrund von Ressourcenbeschränkungen abgestürzt und neu gestartet wurde. Alle zuvor eingerichteten Systemzustände, Abhängigkeiten oder Umgebungsvariablen sind möglicherweise verloren gegangen.",
|
||||
"uk": "Дію не виконано. Можливо, це сталося через натискання користувачем кнопки зупинки або через збій та перезапуск системи виконання через обмеження ресурсів. Можливо, було втрачено будь-який раніше встановлений стан системи, залежності або змінні середовища."
|
||||
},
|
||||
"DIFF_VIEWER$LOADING": {
|
||||
"en": "Loading changes...",
|
||||
@@ -11823,38 +11791,6 @@
|
||||
"de": "OpenHands aktualisiert den Microagenten basierend auf Ihren Anweisungen.",
|
||||
"uk": "OpenHands оновить мікроагента відповідно до ваших інструкцій."
|
||||
},
|
||||
"SETTINGS$GIT_USERNAME": {
|
||||
"en": "Git Username",
|
||||
"ja": "Gitユーザー名",
|
||||
"zh-CN": "Git用户名",
|
||||
"zh-TW": "Git使用者名稱",
|
||||
"ko-KR": "Git 사용자 이름",
|
||||
"no": "Git-brukernavn",
|
||||
"it": "Nome utente Git",
|
||||
"pt": "Nome de usuário Git",
|
||||
"es": "Nombre de usuario Git",
|
||||
"ar": "اسم مستخدم Git",
|
||||
"fr": "Nom d'utilisateur Git",
|
||||
"tr": "Git Kullanıcı Adı",
|
||||
"de": "Git-Benutzername",
|
||||
"uk": "Ім'я користувача Git"
|
||||
},
|
||||
"SETTINGS$GIT_EMAIL": {
|
||||
"en": "Git Email",
|
||||
"ja": "Gitメールアドレス",
|
||||
"zh-CN": "Git电子邮件",
|
||||
"zh-TW": "Git電子郵件",
|
||||
"ko-KR": "Git 이메일",
|
||||
"no": "Git-e-post",
|
||||
"it": "Email Git",
|
||||
"pt": "Email Git",
|
||||
"es": "Correo electrónico Git",
|
||||
"ar": "بريد Git الإلكتروني",
|
||||
"fr": "Email Git",
|
||||
"tr": "Git E-posta",
|
||||
"de": "Git-E-Mail",
|
||||
"uk": "Електронна пошта Git"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$TITLE": {
|
||||
"en": "Project Management",
|
||||
"ja": "プロジェクト管理",
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
// Generate a list of mock repositories with realistic data
|
||||
const generateMockRepositories = (
|
||||
count: number,
|
||||
provider: Provider,
|
||||
): GitRepository[] =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `${i + 1}`,
|
||||
full_name: `user/repo-${i + 1}`,
|
||||
git_provider: provider,
|
||||
is_public: Math.random() > 0.3, // 70% chance of being public
|
||||
stargazers_count: Math.floor(Math.random() * 1000),
|
||||
pushed_at: new Date(
|
||||
Date.now() - Math.random() * 90 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(), // Last 90 days
|
||||
owner_type: Math.random() > 0.7 ? "organization" : "user", // 30% chance of being organization
|
||||
}));
|
||||
|
||||
// Mock repositories for each provider
|
||||
const MOCK_REPOSITORIES = {
|
||||
github: generateMockRepositories(120, "github"),
|
||||
gitlab: generateMockRepositories(120, "gitlab"),
|
||||
bitbucket: generateMockRepositories(120, "bitbucket"),
|
||||
};
|
||||
|
||||
export const GIT_REPOSITORY_HANDLERS = [
|
||||
http.get("/api/user/repositories", async ({ request }) => {
|
||||
await delay(500); // Simulate network delay
|
||||
|
||||
const url = new URL(request.url);
|
||||
const selectedProvider = url.searchParams.get("selected_provider");
|
||||
const page = parseInt(url.searchParams.get("page") || "1", 10);
|
||||
const perPage = parseInt(url.searchParams.get("per_page") || "30", 10);
|
||||
const sort = url.searchParams.get("sort") || "pushed";
|
||||
const installationId = url.searchParams.get("installation_id");
|
||||
|
||||
// Simulate authentication error if no provider token
|
||||
if (!selectedProvider) {
|
||||
return HttpResponse.json(
|
||||
"Git provider token required. (such as GitHub).",
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get repositories for the selected provider
|
||||
const repositories =
|
||||
MOCK_REPOSITORIES[selectedProvider as keyof typeof MOCK_REPOSITORIES] ||
|
||||
[];
|
||||
|
||||
// Sort repositories based on the sort parameter
|
||||
let sortedRepos = [...repositories];
|
||||
if (sort === "pushed") {
|
||||
sortedRepos.sort(
|
||||
(a, b) =>
|
||||
new Date(b.pushed_at!).getTime() - new Date(a.pushed_at!).getTime(),
|
||||
);
|
||||
} else if (sort === "stars") {
|
||||
sortedRepos.sort(
|
||||
(a, b) => (b.stargazers_count || 0) - (a.stargazers_count || 0),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle installation filtering (for GitHub Apps)
|
||||
if (installationId && selectedProvider === "github") {
|
||||
// Simulate filtering by installation - in real API this would filter by access
|
||||
const installationIndex = parseInt(installationId, 10) || 0;
|
||||
const startRepo = installationIndex * 20; // Each installation has ~20 repos
|
||||
sortedRepos = sortedRepos.slice(startRepo, startRepo + 20);
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
const startIndex = (page - 1) * perPage;
|
||||
const endIndex = startIndex + perPage;
|
||||
const paginatedRepos = sortedRepos.slice(startIndex, endIndex);
|
||||
const hasNextPage = endIndex < sortedRepos.length;
|
||||
const hasPrevPage = page > 1;
|
||||
const totalPages = Math.ceil(sortedRepos.length / perPage);
|
||||
|
||||
// Generate GitHub-style link header for pagination
|
||||
let linkHeader = "";
|
||||
if (hasNextPage || hasPrevPage) {
|
||||
const links = [];
|
||||
if (hasPrevPage) {
|
||||
links.push(
|
||||
`</api/user/repositories?page=${page - 1}&per_page=${perPage}>; rel="prev"`,
|
||||
);
|
||||
}
|
||||
if (hasNextPage) {
|
||||
links.push(
|
||||
`</api/user/repositories?page=${page + 1}&per_page=${perPage}>; rel="next"`,
|
||||
);
|
||||
}
|
||||
links.push(
|
||||
`</api/user/repositories?page=${totalPages}&per_page=${perPage}>; rel="last"`,
|
||||
);
|
||||
links.push(
|
||||
`</api/user/repositories?page=1&per_page=${perPage}>; rel="first"`,
|
||||
);
|
||||
linkHeader = links.join(", ");
|
||||
}
|
||||
|
||||
// Add link_header to the first repository if pagination info exists
|
||||
const responseRepos = [...paginatedRepos];
|
||||
if (responseRepos.length > 0 && linkHeader) {
|
||||
responseRepos[0] = { ...responseRepos[0], link_header: linkHeader };
|
||||
}
|
||||
|
||||
// Return response as direct Repository array (matching real API)
|
||||
return HttpResponse.json(responseRepos);
|
||||
}),
|
||||
|
||||
http.get("/api/user/search/repositories", async ({ request }) => {
|
||||
await delay(300); // Simulate network delay
|
||||
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get("query") || "";
|
||||
const selectedProvider = url.searchParams.get("selected_provider");
|
||||
const perPage = parseInt(url.searchParams.get("per_page") || "5", 10);
|
||||
const sort = url.searchParams.get("sort") || "stars";
|
||||
const order = url.searchParams.get("order") || "desc";
|
||||
|
||||
// Simulate authentication error if no provider token
|
||||
if (!selectedProvider) {
|
||||
return HttpResponse.json("Git provider token required.", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
// Get repositories for the selected provider
|
||||
const repositories =
|
||||
MOCK_REPOSITORIES[selectedProvider as keyof typeof MOCK_REPOSITORIES] ||
|
||||
[];
|
||||
|
||||
// Filter repositories by search query
|
||||
const filteredRepos = repositories.filter((repo) =>
|
||||
repo.full_name.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
// Sort repositories
|
||||
const sortedRepos = [...filteredRepos];
|
||||
if (sort === "stars") {
|
||||
sortedRepos.sort((a, b) => {
|
||||
const aStars = a.stargazers_count || 0;
|
||||
const bStars = b.stargazers_count || 0;
|
||||
return order === "desc" ? bStars - aStars : aStars - bStars;
|
||||
});
|
||||
}
|
||||
|
||||
// Limit results
|
||||
const limitedRepos = sortedRepos.slice(0, perPage);
|
||||
|
||||
return HttpResponse.json(limitedRepos);
|
||||
}),
|
||||
];
|
||||
@@ -8,10 +8,9 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
|
||||
import { GitUser } from "#/types/git";
|
||||
import { GitRepository, GitUser } from "#/types/git";
|
||||
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
|
||||
import { SECRETS_HANDLERS } from "./secrets-handlers";
|
||||
import { GIT_REPOSITORY_HANDLERS } from "./git-repository-handlers";
|
||||
|
||||
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
|
||||
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
@@ -25,7 +24,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
|
||||
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
|
||||
remote_runtime_resource_factor:
|
||||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
provider_tokens_set: { github: null, gitlab: null, bitbucket: null },
|
||||
provider_tokens_set: DEFAULT_SETTINGS.PROVIDER_TOKENS_SET,
|
||||
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
|
||||
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
|
||||
enable_proactive_conversation_starters:
|
||||
@@ -112,7 +111,6 @@ const openHandsHandlers = [
|
||||
"gpt-4o-mini",
|
||||
"anthropic/claude-3.5",
|
||||
"anthropic/claude-sonnet-4-20250514",
|
||||
"openhands/claude-sonnet-4-20250514",
|
||||
"sambanova/Meta-Llama-3.1-8B-Instruct",
|
||||
]),
|
||||
),
|
||||
@@ -140,8 +138,25 @@ export const handlers = [
|
||||
...FILE_SERVICE_HANDLERS,
|
||||
...TASK_SUGGESTIONS_HANDLERS,
|
||||
...SECRETS_HANDLERS,
|
||||
...GIT_REPOSITORY_HANDLERS,
|
||||
...openHandsHandlers,
|
||||
http.get("/api/user/repositories", () => {
|
||||
const data: GitRepository[] = [
|
||||
{
|
||||
id: "1",
|
||||
full_name: "octocat/hello-world",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "octocat/earth",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
];
|
||||
|
||||
return HttpResponse.json(data);
|
||||
}),
|
||||
http.get("/api/user/info", () => {
|
||||
const user: GitUser = {
|
||||
id: "1",
|
||||
@@ -190,6 +205,9 @@ export const handlers = [
|
||||
|
||||
if (!settings) return HttpResponse.json(null, { status: 404 });
|
||||
|
||||
if (Object.keys(settings.provider_tokens_set).length > 0)
|
||||
settings.provider_tokens_set = {};
|
||||
|
||||
return HttpResponse.json(settings);
|
||||
}),
|
||||
http.post("/api/settings", async ({ request }) => {
|
||||
@@ -197,7 +215,18 @@ export const handlers = [
|
||||
const body = await request.json();
|
||||
|
||||
if (body) {
|
||||
MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
|
||||
let newSettings: Partial<PostApiSettings> = {};
|
||||
if (typeof body === "object") {
|
||||
newSettings = { ...body };
|
||||
}
|
||||
|
||||
const fullSettings = {
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
...MOCK_USER_PREFERENCES.settings,
|
||||
...newSettings,
|
||||
};
|
||||
|
||||
MOCK_USER_PREFERENCES.settings = fullSettings;
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,6 @@ function AppSettingsScreen() {
|
||||
] = React.useState(false);
|
||||
const [maxBudgetPerTaskHasChanged, setMaxBudgetPerTaskHasChanged] =
|
||||
React.useState(false);
|
||||
const [gitUserNameHasChanged, setGitUserNameHasChanged] =
|
||||
React.useState(false);
|
||||
const [gitUserEmailHasChanged, setGitUserEmailHasChanged] =
|
||||
React.useState(false);
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
const languageLabel = formData.get("language-input")?.toString();
|
||||
@@ -66,13 +62,6 @@ function AppSettingsScreen() {
|
||||
?.toString();
|
||||
const maxBudgetPerTask = parseMaxBudgetPerTask(maxBudgetPerTaskValue || "");
|
||||
|
||||
const gitUserName =
|
||||
formData.get("git-user-name-input")?.toString() ||
|
||||
DEFAULT_SETTINGS.GIT_USER_NAME;
|
||||
const gitUserEmail =
|
||||
formData.get("git-user-email-input")?.toString() ||
|
||||
DEFAULT_SETTINGS.GIT_USER_EMAIL;
|
||||
|
||||
saveSettings(
|
||||
{
|
||||
LANGUAGE: language,
|
||||
@@ -80,8 +69,6 @@ function AppSettingsScreen() {
|
||||
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: enableProactiveConversations,
|
||||
MAX_BUDGET_PER_TASK: maxBudgetPerTask,
|
||||
GIT_USER_NAME: gitUserName,
|
||||
GIT_USER_EMAIL: gitUserEmail,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -98,8 +85,6 @@ function AppSettingsScreen() {
|
||||
setSoundNotificationsSwitchHasChanged(false);
|
||||
setProactiveConversationsSwitchHasChanged(false);
|
||||
setMaxBudgetPerTaskHasChanged(false);
|
||||
setGitUserNameHasChanged(false);
|
||||
setGitUserEmailHasChanged(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -142,24 +127,12 @@ function AppSettingsScreen() {
|
||||
setMaxBudgetPerTaskHasChanged(newValue !== currentValue);
|
||||
};
|
||||
|
||||
const checkIfGitUserNameHasChanged = (value: string) => {
|
||||
const currentValue = settings?.GIT_USER_NAME;
|
||||
setGitUserNameHasChanged(value !== currentValue);
|
||||
};
|
||||
|
||||
const checkIfGitUserEmailHasChanged = (value: string) => {
|
||||
const currentValue = settings?.GIT_USER_EMAIL;
|
||||
setGitUserEmailHasChanged(value !== currentValue);
|
||||
};
|
||||
|
||||
const formIsClean =
|
||||
!languageInputHasChanged &&
|
||||
!analyticsSwitchHasChanged &&
|
||||
!soundNotificationsSwitchHasChanged &&
|
||||
!proactiveConversationsSwitchHasChanged &&
|
||||
!maxBudgetPerTaskHasChanged &&
|
||||
!gitUserNameHasChanged &&
|
||||
!gitUserEmailHasChanged;
|
||||
!maxBudgetPerTaskHasChanged;
|
||||
|
||||
const shouldBeLoading = !settings || isLoading || isPending;
|
||||
|
||||
@@ -221,34 +194,6 @@ function AppSettingsScreen() {
|
||||
step={1}
|
||||
className="w-full max-w-[680px]" // Match the width of the language field
|
||||
/>
|
||||
|
||||
<div className="border-t border-t-tertiary pt-6 mt-2">
|
||||
<h3 className="text-lg font-medium mb-4">
|
||||
{t(I18nKey.SETTINGS$GIT_SETTINGS)}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsInput
|
||||
testId="git-user-name-input"
|
||||
name="git-user-name-input"
|
||||
type="text"
|
||||
label={t(I18nKey.SETTINGS$GIT_USERNAME)}
|
||||
defaultValue={settings.GIT_USER_NAME || ""}
|
||||
onChange={checkIfGitUserNameHasChanged}
|
||||
placeholder="Username for git commits"
|
||||
className="w-full max-w-[680px]"
|
||||
/>
|
||||
<SettingsInput
|
||||
testId="git-user-email-input"
|
||||
name="git-user-email-input"
|
||||
type="email"
|
||||
label={t(I18nKey.SETTINGS$GIT_EMAIL)}
|
||||
defaultValue={settings.GIT_USER_EMAIL || ""}
|
||||
onChange={checkIfGitUserEmailHasChanged}
|
||||
placeholder="Email for git commits"
|
||||
className="w-full max-w-[680px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { redirect } from "react-router";
|
||||
import { Route } from "./+types/settings";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
@@ -5,13 +7,23 @@ import { MicroagentManagementContent } from "#/components/features/microagent-ma
|
||||
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
|
||||
import { EventHandler } from "#/wrapper/event-handler";
|
||||
|
||||
export const clientLoader = async () => {
|
||||
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const { pathname } = url;
|
||||
|
||||
let config = queryClient.getQueryData<GetConfigResponse>(["config"]);
|
||||
if (!config) {
|
||||
config = await OpenHands.getConfig();
|
||||
queryClient.setQueryData<GetConfigResponse>(["config"], config);
|
||||
}
|
||||
|
||||
const shouldHideMicroagentManagement =
|
||||
config?.FEATURE_FLAGS.HIDE_MICROAGENT_MANAGEMENT;
|
||||
|
||||
if (shouldHideMicroagentManagement && pathname === "/microagent-management") {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@ export default function MainApp() {
|
||||
const gitHubAuthUrl = useGitHubAuthUrl({
|
||||
appMode: config.data?.APP_MODE || null,
|
||||
gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
|
||||
authUrl: config.data?.AUTH_URL,
|
||||
});
|
||||
|
||||
// When on TOS page, we don't use the GitHub auth URL
|
||||
@@ -220,7 +219,6 @@ export default function MainApp() {
|
||||
githubAuthUrl={effectiveGitHubAuthUrl}
|
||||
appMode={config.data?.APP_MODE}
|
||||
providersConfigured={config.data?.PROVIDERS_CONFIGURED}
|
||||
authUrl={config.data?.AUTH_URL}
|
||||
/>
|
||||
)}
|
||||
{renderReAuthModal && <ReauthModal />}
|
||||
|
||||
@@ -96,6 +96,10 @@ function SecretsSettingsScreen() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{secrets?.length === 0 && view === "list" && (
|
||||
<p data-testid="no-secrets-message">{t("SECRETS$NO_SECRETS_FOUND")}</p>
|
||||
)}
|
||||
|
||||
{!shouldRenderConnectToGitButton && view === "list" && (
|
||||
<BrandButton
|
||||
testId="add-secret-button"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Settings } from "#/types/settings";
|
||||
export const LATEST_SETTINGS_VERSION = 5;
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
LLM_MODEL: "openhands/claude-sonnet-4-20250514",
|
||||
LLM_MODEL: "anthropic/claude-sonnet-4-20250514",
|
||||
LLM_BASE_URL: "",
|
||||
AGENT: "CodeActAgent",
|
||||
LANGUAGE: "en",
|
||||
@@ -26,8 +26,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
sse_servers: [],
|
||||
stdio_servers: [],
|
||||
},
|
||||
GIT_USER_NAME: "openhands",
|
||||
GIT_USER_EMAIL: "openhands@all-hands.dev",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,8 +50,6 @@ export type Settings = {
|
||||
MAX_BUDGET_PER_TASK: number | null;
|
||||
EMAIL?: string;
|
||||
EMAIL_VERIFIED?: boolean;
|
||||
GIT_USER_NAME?: string;
|
||||
GIT_USER_EMAIL?: string;
|
||||
};
|
||||
|
||||
export type ApiSettings = {
|
||||
@@ -78,8 +76,6 @@ export type ApiSettings = {
|
||||
};
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
git_user_name?: string;
|
||||
git_user_email?: string;
|
||||
};
|
||||
|
||||
export type PostSettings = Settings & {
|
||||
|
||||
@@ -4,41 +4,23 @@
|
||||
* @param requestUrl The URL of the request
|
||||
* @returns The URL to redirect to for OAuth
|
||||
*/
|
||||
export const generateAuthUrl = (
|
||||
identityProvider: string,
|
||||
requestUrl: URL,
|
||||
authUrl?: string,
|
||||
) => {
|
||||
export const generateAuthUrl = (identityProvider: string, requestUrl: URL) => {
|
||||
// Use HTTPS protocol unless the host is localhost
|
||||
const protocol =
|
||||
requestUrl.hostname === "localhost" ? requestUrl.protocol : "https:";
|
||||
const redirectUri = `${protocol}//${requestUrl.host}/oauth/keycloak/callback`;
|
||||
let authUrl = requestUrl.hostname
|
||||
.replace(/(^|\.)staging\.all-hands\.dev$/, "$1auth.staging.all-hands.dev")
|
||||
.replace(/(^|\.)app\.all-hands\.dev$/, "auth.app.all-hands.dev")
|
||||
.replace(/(^|\.)localhost$/, "auth.staging.all-hands.dev");
|
||||
|
||||
let finalAuthUrl: string;
|
||||
|
||||
if (authUrl) {
|
||||
// Ensure https:// is prepended and remove any accidental duplicate slashes
|
||||
finalAuthUrl = `https://${authUrl.replace(/^https?:\/\//, "")}`;
|
||||
} else {
|
||||
finalAuthUrl = requestUrl.hostname
|
||||
.replace(/(^|\.)staging\.all-hands\.dev$/, "$1auth.staging.all-hands.dev")
|
||||
.replace(/(^|\.)app\.all-hands\.dev$/, "auth.app.all-hands.dev")
|
||||
.replace(/(^|\.)localhost$/, "auth.staging.all-hands.dev");
|
||||
|
||||
// If no replacements matched, prepend "auth." (excluding localhost)
|
||||
if (
|
||||
finalAuthUrl === requestUrl.hostname &&
|
||||
requestUrl.hostname !== "localhost"
|
||||
) {
|
||||
finalAuthUrl = `auth.${requestUrl.hostname}`;
|
||||
}
|
||||
|
||||
finalAuthUrl = `https://${finalAuthUrl}`;
|
||||
// If no replacements matched, prepend "auth." (excluding localhost)
|
||||
if (authUrl === requestUrl.hostname && requestUrl.hostname !== "localhost") {
|
||||
authUrl = `auth.${requestUrl.hostname}`;
|
||||
}
|
||||
|
||||
const scope = "openid email profile"; // OAuth scope - not user-facing
|
||||
const separator = requestUrl.search ? "&" : "?";
|
||||
const cleanHref = requestUrl.href.replace(/\/$/, "");
|
||||
const state = `${cleanHref}${separator}login_method=${identityProvider}`;
|
||||
return `${finalAuthUrl}/realms/allhands/protocol/openid-connect/auth?client_id=allhands&kc_idp_hint=${identityProvider}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}`;
|
||||
return `https://${authUrl}/realms/allhands/protocol/openid-connect/auth?client_id=allhands&kc_idp_hint=${identityProvider}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}`;
|
||||
};
|
||||
|
||||
@@ -104,24 +104,6 @@ export const formatTimestamp = (timestamp: string) =>
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
export const shouldUseInstallationRepos = (
|
||||
provider: Provider,
|
||||
app_mode: "saas" | "oss" | undefined,
|
||||
) => {
|
||||
if (!provider) return false;
|
||||
|
||||
switch (provider) {
|
||||
case "bitbucket":
|
||||
return true;
|
||||
case "gitlab":
|
||||
return false;
|
||||
case "github":
|
||||
return app_mode === "saas";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getGitProviderBaseUrl = (gitProvider: Provider): string => {
|
||||
switch (gitProvider) {
|
||||
case "github":
|
||||
|
||||
@@ -205,11 +205,20 @@ async def modify_llm_settings_basic(
|
||||
provider = verified_providers[choice_index]
|
||||
else:
|
||||
# User selected "Select another provider" - use manual selection
|
||||
# Define a validator function that prints an error message
|
||||
def provider_validator(x):
|
||||
is_valid = x in organized_models
|
||||
if not is_valid:
|
||||
print_formatted_text(
|
||||
HTML('<grey>Invalid provider selected: {}</grey>'.format(x))
|
||||
)
|
||||
return is_valid
|
||||
|
||||
provider = await get_validated_input(
|
||||
session,
|
||||
'(Step 1/3) Select LLM Provider (TAB for options, CTRL-c to cancel): ',
|
||||
completer=provider_completer,
|
||||
validator=lambda x: x in organized_models,
|
||||
validator=provider_validator,
|
||||
error_message='Invalid provider selected',
|
||||
)
|
||||
|
||||
|
||||
@@ -82,12 +82,8 @@ from openhands.storage.files import FileStore
|
||||
TRAFFIC_CONTROL_REMINDER = (
|
||||
"Please click on resume button if you'd like to continue, or start a new task."
|
||||
)
|
||||
ERROR_ACTION_NOT_EXECUTED_STOPPED_ID = 'AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_STOPPED'
|
||||
ERROR_ACTION_NOT_EXECUTED_ERROR_ID = 'AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_ERROR'
|
||||
ERROR_ACTION_NOT_EXECUTED_STOPPED = (
|
||||
'Stop button pressed. The action has not been executed.'
|
||||
)
|
||||
ERROR_ACTION_NOT_EXECUTED_ERROR = 'The action has not been executed due to a runtime error. The runtime system may have crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.'
|
||||
ERROR_ACTION_NOT_EXECUTED_ID = 'AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED'
|
||||
ERROR_ACTION_NOT_EXECUTED = 'The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.'
|
||||
|
||||
|
||||
class AgentController:
|
||||
@@ -556,17 +552,9 @@ class AgentController:
|
||||
|
||||
# make a new ErrorObservation with the tool call metadata
|
||||
if not found_observation:
|
||||
# Use different messages and IDs based on whether the agent was stopped by user or due to error
|
||||
if self.state.agent_state == AgentState.STOPPED:
|
||||
error_content = ERROR_ACTION_NOT_EXECUTED_STOPPED
|
||||
error_id = ERROR_ACTION_NOT_EXECUTED_STOPPED_ID
|
||||
else: # AgentState.ERROR
|
||||
error_content = ERROR_ACTION_NOT_EXECUTED_ERROR
|
||||
error_id = ERROR_ACTION_NOT_EXECUTED_ERROR_ID
|
||||
|
||||
obs = ErrorObservation(
|
||||
content=error_content,
|
||||
error_id=error_id,
|
||||
content=ERROR_ACTION_NOT_EXECUTED,
|
||||
error_id=ERROR_ACTION_NOT_EXECUTED_ID,
|
||||
)
|
||||
obs.tool_call_metadata = self._pending_action.tool_call_metadata
|
||||
obs._cause = self._pending_action.id # type: ignore[attr-defined]
|
||||
@@ -592,17 +580,14 @@ class AgentController:
|
||||
if new_state == self.state.agent_state:
|
||||
return
|
||||
|
||||
# Store old state for control limits check
|
||||
old_state = self.state.agent_state
|
||||
|
||||
# Update agent state BEFORE calling _reset() so _reset() sees the correct state
|
||||
self.state.agent_state = new_state
|
||||
|
||||
if new_state in (AgentState.STOPPED, AgentState.ERROR):
|
||||
self._reset()
|
||||
|
||||
# User is allowing to check control limits and expand them if applicable
|
||||
if old_state == AgentState.ERROR and new_state == AgentState.RUNNING:
|
||||
if (
|
||||
self.state.agent_state == AgentState.ERROR
|
||||
and new_state == AgentState.RUNNING
|
||||
):
|
||||
self.state_tracker.maybe_increase_control_flags_limits(self.headless_mode)
|
||||
|
||||
if self._pending_action is not None and (
|
||||
@@ -618,6 +603,8 @@ class AgentController:
|
||||
self._pending_action._id = None # type: ignore[attr-defined]
|
||||
self.event_stream.add_event(self._pending_action, EventSource.AGENT)
|
||||
|
||||
self.state.agent_state = new_state
|
||||
|
||||
# Create observation with reason field if it's an error state
|
||||
reason = ''
|
||||
if new_state == AgentState.ERROR:
|
||||
|
||||
@@ -74,7 +74,7 @@ class MCPStdioServerConfig(BaseModel):
|
||||
args: list[str] = Field(default_factory=list)
|
||||
env: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
@field_validator('name', mode='before')
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_server_name(cls, v: str) -> str:
|
||||
"""Validate server name for stdio MCP servers."""
|
||||
@@ -91,7 +91,7 @@ class MCPStdioServerConfig(BaseModel):
|
||||
|
||||
return v
|
||||
|
||||
@field_validator('command', mode='before')
|
||||
@field_validator('command')
|
||||
@classmethod
|
||||
def validate_command(cls, v: str) -> str:
|
||||
"""Validate command for stdio MCP servers."""
|
||||
@@ -114,7 +114,6 @@ class MCPStdioServerConfig(BaseModel):
|
||||
"""Parse arguments from string or return list as-is.
|
||||
|
||||
Supports shell-like argument parsing using shlex.split().
|
||||
|
||||
Examples:
|
||||
- "-y mcp-remote https://example.com"
|
||||
- '--config "path with spaces" --debug'
|
||||
@@ -190,7 +189,7 @@ class MCPSHTTPServerConfig(BaseModel):
|
||||
url: str
|
||||
api_key: str | None = None
|
||||
|
||||
@field_validator('url', mode='before')
|
||||
@field_validator('url')
|
||||
@classmethod
|
||||
def validate_url(cls, v: str) -> str:
|
||||
"""Validate URL format for MCP servers."""
|
||||
@@ -203,12 +202,12 @@ class MCPConfig(BaseModel):
|
||||
Attributes:
|
||||
sse_servers: List of MCP SSE server configs
|
||||
stdio_servers: List of MCP stdio server configs. These servers will be added to the MCP Router running inside runtime container.
|
||||
shttp_servers: List of MCP HTTP server configs.
|
||||
"""
|
||||
|
||||
sse_servers: list[MCPSSEServerConfig] = Field(default_factory=list)
|
||||
stdio_servers: list[MCPStdioServerConfig] = Field(default_factory=list)
|
||||
shttp_servers: list[MCPSHTTPServerConfig] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
@staticmethod
|
||||
@@ -253,7 +252,8 @@ class MCPConfig(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def from_toml_section(cls, data: dict) -> dict[str, 'MCPConfig']:
|
||||
"""Create a mapping of MCPConfig instances from a toml dictionary representing the [mcp] section.
|
||||
"""
|
||||
Create a mapping of MCPConfig instances from a toml dictionary representing the [mcp] section.
|
||||
|
||||
The configuration is built from all keys in data.
|
||||
|
||||
@@ -306,7 +306,7 @@ class MCPConfig(BaseModel):
|
||||
class OpenHandsMCPConfig:
|
||||
@staticmethod
|
||||
def add_search_engine(app_config: 'OpenHandsConfig') -> MCPStdioServerConfig | None:
|
||||
"""Add search engine to the MCP config."""
|
||||
"""Add search engine to the MCP config"""
|
||||
if (
|
||||
app_config.search_api_key
|
||||
and app_config.search_api_key.get_secret_value().startswith('tvly-')
|
||||
@@ -327,23 +327,21 @@ class OpenHandsMCPConfig:
|
||||
def create_default_mcp_server_config(
|
||||
host: str, config: 'OpenHandsConfig', user_id: str | None = None
|
||||
) -> tuple[MCPSHTTPServerConfig | None, list[MCPStdioServerConfig]]:
|
||||
"""Create a default MCP server configuration.
|
||||
"""
|
||||
Create a default MCP server configuration.
|
||||
|
||||
Args:
|
||||
host: Host string
|
||||
config: OpenHandsConfig
|
||||
user_id: Optional user ID for the MCP server
|
||||
Returns:
|
||||
tuple[MCPSHTTPServerConfig | None, list[MCPStdioServerConfig]]: A tuple containing the default SHTTP server configuration (or None) and a list of MCP stdio server configurations
|
||||
"""
|
||||
|
||||
stdio_servers = []
|
||||
search_engine_stdio_server = OpenHandsMCPConfig.add_search_engine(config)
|
||||
if search_engine_stdio_server:
|
||||
stdio_servers.append(search_engine_stdio_server)
|
||||
|
||||
shttp_servers = MCPSHTTPServerConfig(url=f'http://{host}/mcp/mcp', api_key=None)
|
||||
|
||||
return shttp_servers, stdio_servers
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import platform
|
||||
import sys
|
||||
from ast import literal_eval
|
||||
from types import UnionType
|
||||
from typing import Any, MutableMapping, get_args, get_origin, get_type_hints
|
||||
from typing import MutableMapping, get_args, get_origin, get_type_hints
|
||||
from uuid import uuid4
|
||||
|
||||
import toml
|
||||
@@ -75,7 +75,6 @@ def load_from_env(
|
||||
# e.g. LLM_BASE_URL
|
||||
env_var_name = (prefix + field_name).upper()
|
||||
|
||||
cast_value: Any
|
||||
if isinstance(field_value, BaseModel):
|
||||
set_attr_from_env(field_value, prefix=field_name + '_')
|
||||
|
||||
@@ -95,7 +94,7 @@ def load_from_env(
|
||||
# Attempt to cast the env var to type hinted in the dataclass
|
||||
if field_type is bool:
|
||||
cast_value = str(value).lower() in ['true', '1']
|
||||
# parse dicts and lists like SANDBOX_RUNTIME_STARTUP_ENV_VARS and SANDBOX_RUNTIME_EXTRA_BUILD_ARGS
|
||||
# parse dicts and lists like SANDBOX_RUNTIME_STARTUP_ENV_VARS and SANDBOX_RUNTIME_EXTRA_BUILD_ARGS │
|
||||
elif (
|
||||
get_origin(field_type) is dict
|
||||
or get_origin(field_type) is list
|
||||
@@ -103,20 +102,6 @@ def load_from_env(
|
||||
or field_type is list
|
||||
):
|
||||
cast_value = literal_eval(value)
|
||||
# If it's a list of Pydantic models
|
||||
if get_origin(field_type) is list:
|
||||
inner_type = get_args(field_type)[
|
||||
0
|
||||
] # e.g., MCPSHTTPServerConfig
|
||||
if isinstance(inner_type, type) and issubclass(
|
||||
inner_type, BaseModel
|
||||
):
|
||||
cast_value = [
|
||||
inner_type(**item)
|
||||
if isinstance(item, dict)
|
||||
else item
|
||||
for item in cast_value
|
||||
]
|
||||
else:
|
||||
if field_type is not None:
|
||||
cast_value = field_type(value)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -11,7 +10,6 @@ from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
ProviderType,
|
||||
Repository,
|
||||
@@ -25,7 +23,7 @@ from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
class BitBucketService(BaseGitService, GitService):
|
||||
"""Default implementation of GitService for Bitbucket integration.
|
||||
|
||||
This is an extension point in OpenHands that allows applications to customize Bitbucket
|
||||
@@ -188,106 +186,16 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
email=None, # Bitbucket API doesn't return email in this endpoint
|
||||
)
|
||||
|
||||
def _parse_repository(
|
||||
self, repo: dict, link_header: str | None = None
|
||||
) -> Repository:
|
||||
"""
|
||||
Parse a Bitbucket API repository response into a Repository object.
|
||||
|
||||
Args:
|
||||
repo: Repository data from Bitbucket API
|
||||
link_header: Optional link header for pagination
|
||||
|
||||
Returns:
|
||||
Repository object
|
||||
"""
|
||||
|
||||
repo_id = repo.get('uuid', '')
|
||||
|
||||
workspace_slug = repo.get('workspace', {}).get('slug', '')
|
||||
repo_slug = repo.get('slug', '')
|
||||
full_name = (
|
||||
f'{workspace_slug}/{repo_slug}' if workspace_slug and repo_slug else ''
|
||||
)
|
||||
|
||||
is_public = not repo.get('is_private', True)
|
||||
owner_type = OwnerType.ORGANIZATION
|
||||
main_branch = repo.get('mainbranch', {}).get('name')
|
||||
|
||||
return Repository(
|
||||
id=repo_id,
|
||||
full_name=full_name, # type: ignore[arg-type]
|
||||
git_provider=ProviderType.BITBUCKET,
|
||||
is_public=is_public,
|
||||
stargazers_count=None, # Bitbucket doesn't have stars
|
||||
pushed_at=repo.get('updated_on'),
|
||||
owner_type=owner_type,
|
||||
link_header=link_header,
|
||||
main_branch=main_branch,
|
||||
)
|
||||
|
||||
async def search_repositories(
|
||||
self, query: str, per_page: int, sort: str, order: str, public: bool
|
||||
self,
|
||||
query: str,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
order: str,
|
||||
) -> list[Repository]:
|
||||
"""Search for repositories."""
|
||||
repositories = []
|
||||
|
||||
if public:
|
||||
# Extract workspace and repo from URL
|
||||
# URL format: https://{domain}/{workspace}/{repo}/{additional_params}
|
||||
# Split by '/' and find workspace and repo parts
|
||||
url_parts = query.split('/')
|
||||
if len(url_parts) >= 5: # https:, '', domain, workspace, repo
|
||||
workspace_slug = url_parts[3]
|
||||
repo_name = url_parts[4]
|
||||
|
||||
repo = await self.get_repository_details_from_repo_name(
|
||||
f'{workspace_slug}/{repo_name}'
|
||||
)
|
||||
repositories.append(repo)
|
||||
|
||||
return repositories
|
||||
|
||||
# Search for repos once workspace prefix exists
|
||||
if '/' in query:
|
||||
workspace_slug, repo_query = query.split('/', 1)
|
||||
return await self.get_paginated_repos(
|
||||
1, per_page, sort, workspace_slug, repo_query
|
||||
)
|
||||
|
||||
all_installations = await self.get_installations()
|
||||
|
||||
# Workspace prefix isn't complete. Search workspace names and repos underneath each workspace
|
||||
matching_workspace_slugs = [
|
||||
installation for installation in all_installations if query in installation
|
||||
]
|
||||
for workspace_slug in matching_workspace_slugs:
|
||||
# Get repositories where query matches workspace name
|
||||
try:
|
||||
repos = await self.get_paginated_repos(
|
||||
1, per_page, sort, workspace_slug
|
||||
)
|
||||
repositories.extend(repos)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for workspace_slug in all_installations:
|
||||
# Get repositories in all workspaces where query matches repo name
|
||||
try:
|
||||
repos = await self.get_paginated_repos(
|
||||
1, per_page, sort, workspace_slug, query
|
||||
)
|
||||
repositories.extend(repos)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return repositories
|
||||
|
||||
async def _get_user_workspaces(self) -> list[dict[str, Any]]:
|
||||
"""Get all workspaces the user has access to"""
|
||||
url = f'{self.BASE_URL}/workspaces'
|
||||
data, _ = await self._make_request(url)
|
||||
return data.get('values', [])
|
||||
# Bitbucket doesn't have a dedicated search endpoint like GitHub
|
||||
return []
|
||||
|
||||
async def _fetch_paginated_data(
|
||||
self, url: str, params: dict, max_items: int
|
||||
@@ -324,107 +232,7 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
|
||||
return all_items[:max_items] # Trim to max_items if needed
|
||||
|
||||
async def get_installations(
|
||||
self, query: str | None = None, limit: int = 100
|
||||
) -> list[str]:
|
||||
workspaces_url = f'{self.BASE_URL}/workspaces'
|
||||
params = {}
|
||||
if query:
|
||||
params['q'] = f'name~"{query}"'
|
||||
|
||||
workspaces = await self._fetch_paginated_data(workspaces_url, params, limit)
|
||||
|
||||
installations: list[str] = []
|
||||
for workspace in workspaces:
|
||||
installations.append(workspace['slug'])
|
||||
|
||||
return installations
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
) -> list[Repository]:
|
||||
"""Get paginated repositories for a specific workspace.
|
||||
|
||||
Args:
|
||||
page: The page number to fetch
|
||||
per_page: The number of repositories per page
|
||||
sort: The sort field ('pushed', 'updated', 'created', 'full_name')
|
||||
installation_id: The workspace slug to fetch repositories from (as int, will be converted to string)
|
||||
|
||||
Returns:
|
||||
A list of Repository objects
|
||||
"""
|
||||
if not installation_id:
|
||||
return []
|
||||
|
||||
# Convert installation_id to string for use as workspace_slug
|
||||
workspace_slug = installation_id
|
||||
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
|
||||
|
||||
# Map sort parameter to Bitbucket API compatible values
|
||||
bitbucket_sort = sort
|
||||
if sort == 'pushed':
|
||||
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
|
||||
bitbucket_sort = '-updated_on' # Use negative prefix for descending order
|
||||
elif sort == 'updated':
|
||||
bitbucket_sort = '-updated_on'
|
||||
elif sort == 'created':
|
||||
bitbucket_sort = '-created_on'
|
||||
elif sort == 'full_name':
|
||||
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
|
||||
else:
|
||||
# Default to most recently updated first
|
||||
bitbucket_sort = '-updated_on'
|
||||
|
||||
params = {
|
||||
'pagelen': per_page,
|
||||
'page': page,
|
||||
'sort': bitbucket_sort,
|
||||
}
|
||||
|
||||
if query:
|
||||
params['q'] = f'name~"{query}"'
|
||||
|
||||
response, headers = await self._make_request(workspace_repos_url, params)
|
||||
|
||||
# Extract repositories from the response
|
||||
repos = response.get('values', [])
|
||||
|
||||
# Extract next URL from response
|
||||
next_link = response.get('next', '')
|
||||
|
||||
# Format the link header in a way that the frontend can understand
|
||||
# The frontend expects a format like: <url>; rel="next"
|
||||
# where the URL contains a page parameter
|
||||
formatted_link_header = ''
|
||||
if next_link:
|
||||
# Extract the page number from the next URL if possible
|
||||
page_match = re.search(r'[?&]page=(\d+)', next_link)
|
||||
if page_match:
|
||||
next_page = page_match.group(1)
|
||||
# Format it in a way that extractNextPageFromLink in frontend can parse
|
||||
formatted_link_header = (
|
||||
f'<{workspace_repos_url}?page={next_page}>; rel="next"'
|
||||
)
|
||||
else:
|
||||
# If we can't extract the page, just use the next URL as is
|
||||
formatted_link_header = f'<{next_link}>; rel="next"'
|
||||
|
||||
repositories = [
|
||||
self._parse_repository(repo, link_header=formatted_link_header)
|
||||
for repo in repos
|
||||
]
|
||||
|
||||
return repositories
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user using workspaces endpoint.
|
||||
|
||||
This method gets all repositories (both public and private) that the user has access to
|
||||
@@ -477,7 +285,22 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
)
|
||||
|
||||
for repo in workspace_repos:
|
||||
repositories.append(self._parse_repository(repo))
|
||||
uuid = repo.get('uuid', '')
|
||||
repositories.append(
|
||||
Repository(
|
||||
id=uuid,
|
||||
full_name=f'{repo.get("workspace", {}).get("slug", "")}/{repo.get("slug", "")}',
|
||||
git_provider=ProviderType.BITBUCKET,
|
||||
is_public=repo.get('is_private', True) is False,
|
||||
stargazers_count=None, # Bitbucket doesn't have stars
|
||||
pushed_at=repo.get('updated_on'),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('workspace', {}).get('is_private') is False
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Stop if we've reached the maximum number of repositories
|
||||
if len(repositories) >= MAX_REPOS:
|
||||
@@ -509,7 +332,23 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}'
|
||||
data, _ = await self._make_request(url)
|
||||
|
||||
return self._parse_repository(data)
|
||||
uuid = data.get('uuid', '')
|
||||
main_branch = data.get('mainbranch', {}).get('name')
|
||||
|
||||
return Repository(
|
||||
id=uuid,
|
||||
full_name=f'{data.get("workspace", {}).get("slug", "")}/{data.get("slug", "")}',
|
||||
git_provider=ProviderType.BITBUCKET,
|
||||
is_public=data.get('is_private', True) is False,
|
||||
stargazers_count=None, # Bitbucket doesn't have stars
|
||||
pushed_at=data.get('updated_on'),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if data.get('workspace', {}).get('is_private') is False
|
||||
else OwnerType.USER
|
||||
),
|
||||
main_branch=main_branch,
|
||||
)
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository."""
|
||||
|
||||
@@ -16,7 +16,6 @@ from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
ProviderType,
|
||||
Repository,
|
||||
@@ -31,7 +30,7 @@ from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
class GitHubService(BaseGitService, GitService):
|
||||
"""Default implementation of GitService for GitHub integration.
|
||||
|
||||
TODO: This doesn't seem a good candidate for the get_impl() pattern. What are the abstract methods we should actually separate and implement here?
|
||||
@@ -225,66 +224,14 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
ts = repo.get('pushed_at')
|
||||
return datetime.strptime(ts, '%Y-%m-%dT%H:%M:%SZ') if ts else datetime.min
|
||||
|
||||
def _parse_repository(
|
||||
self, repo: dict, link_header: str | None = None
|
||||
) -> Repository:
|
||||
"""
|
||||
Parse a GitHub API repository response into a Repository object.
|
||||
|
||||
Args:
|
||||
repo: Repository data from GitHub API
|
||||
link_header: Optional link header for pagination
|
||||
|
||||
Returns:
|
||||
Repository object
|
||||
"""
|
||||
return Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('full_name'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=not repo.get('private', True),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('owner', {}).get('type') == 'Organization'
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=link_header,
|
||||
)
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
):
|
||||
params = {'page': str(page), 'per_page': str(per_page)}
|
||||
if installation_id:
|
||||
url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
|
||||
response, headers = await self._make_request(url, params)
|
||||
response = response.get('repositories', [])
|
||||
else:
|
||||
url = f'{self.BASE_URL}/user/repos'
|
||||
params['sort'] = sort
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
next_link: str = headers.get('Link', '')
|
||||
return [
|
||||
self._parse_repository(repo, link_header=next_link) for repo in response
|
||||
]
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitHub API
|
||||
all_repos: list[dict] = []
|
||||
|
||||
if app_mode == AppMode.SAAS:
|
||||
# Get all installation IDs and fetch repos for each one
|
||||
installation_ids = await self.get_installations()
|
||||
installation_ids = await self.get_installation_ids()
|
||||
|
||||
# Iterate through each installation ID
|
||||
for installation_id in installation_ids:
|
||||
@@ -315,47 +262,59 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
all_repos = await self._fetch_paginated_repos(url, params, MAX_REPOS)
|
||||
|
||||
# Convert to Repository objects
|
||||
return [self._parse_repository(repo) for repo in all_repos]
|
||||
return [
|
||||
Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('full_name'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=not repo.get('private', True),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('owner', {}).get('type') == 'Organization'
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
for repo in all_repos
|
||||
]
|
||||
|
||||
async def get_installations(self) -> list[str]:
|
||||
async def get_installation_ids(self) -> list[int]:
|
||||
url = f'{self.BASE_URL}/user/installations'
|
||||
response, _ = await self._make_request(url)
|
||||
installations = response.get('installations', [])
|
||||
return [str(i['id']) for i in installations]
|
||||
return [i['id'] for i in installations]
|
||||
|
||||
async def search_repositories(
|
||||
self, query: str, per_page: int, sort: str, order: str, public: bool
|
||||
self, query: str, per_page: int, sort: str, order: str
|
||||
) -> list[Repository]:
|
||||
url = f'{self.BASE_URL}/search/repositories'
|
||||
# Add is:public to the query to ensure we only search for public repositories
|
||||
query_with_visibility = f'{query} is:public'
|
||||
params = {
|
||||
'q': query_with_visibility,
|
||||
'per_page': per_page,
|
||||
'sort': sort,
|
||||
'order': order,
|
||||
}
|
||||
|
||||
if public:
|
||||
url_parts = query.split('/')
|
||||
if len(url_parts) < 4:
|
||||
return []
|
||||
|
||||
org = url_parts[3]
|
||||
repo_name = url_parts[4]
|
||||
# Add is:public to the query to ensure we only search for public repositories
|
||||
params['q'] = f'in:name {org}/{repo_name} is:public'
|
||||
|
||||
# Perhaps we should go through all orgs and the search for repos under every org
|
||||
# Currently it will only search user repos, and org repos when '/' is in the name
|
||||
if not public and '/' in query:
|
||||
org, repo_query = query.split('/', 1)
|
||||
query_with_user = f'org:{org} in:name {repo_query}'
|
||||
params['q'] = query_with_user
|
||||
elif not public:
|
||||
user = await self.get_user()
|
||||
params['q'] = f'in:name {query} user:{user.login}'
|
||||
|
||||
response, _ = await self._make_request(url, params)
|
||||
repo_items = response.get('items', [])
|
||||
repos = [self._parse_repository(repo) for repo in repo_items]
|
||||
|
||||
repos = [
|
||||
Repository(
|
||||
id=str(repo.get('id')),
|
||||
full_name=repo.get('full_name'),
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=True,
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('owner', {}).get('type') == 'Organization'
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
for repo in repo_items
|
||||
]
|
||||
|
||||
return repos
|
||||
|
||||
@@ -492,7 +451,18 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
url = f'{self.BASE_URL}/repos/{repository}'
|
||||
repo, _ = await self._make_request(url)
|
||||
|
||||
return self._parse_repository(repo)
|
||||
return Repository(
|
||||
id=str(repo.get('id')),
|
||||
full_name=repo.get('full_name'),
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=not repo.get('private', True),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('owner', {}).get('type') == 'Organization'
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository"""
|
||||
|
||||
@@ -241,125 +241,38 @@ class GitLabService(BaseGitService, GitService):
|
||||
company=response.get('organization'),
|
||||
)
|
||||
|
||||
def _parse_repository(
|
||||
self, repo: dict, link_header: str | None = None
|
||||
) -> Repository:
|
||||
"""
|
||||
Parse a GitLab API project response into a Repository object.
|
||||
|
||||
Args:
|
||||
repo: Project data from GitLab API
|
||||
link_header: Optional link header for pagination
|
||||
|
||||
Returns:
|
||||
Repository object
|
||||
"""
|
||||
return Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=repo.get('visibility') == 'public',
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('namespace', {}).get('kind') == 'group'
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=link_header,
|
||||
)
|
||||
|
||||
def _parse_gitlab_url(self, url: str) -> str | None:
|
||||
"""
|
||||
Parse a GitLab URL to extract the repository path.
|
||||
|
||||
Expected format: https://{domain}/{group}/{possibly_subgroup}/{repo}
|
||||
Returns the full path from group onwards (e.g., 'group/subgroup/repo' or 'group/repo')
|
||||
"""
|
||||
try:
|
||||
# Remove protocol and domain
|
||||
if '://' in url:
|
||||
url = url.split('://', 1)[1]
|
||||
if '/' in url:
|
||||
path = url.split('/', 1)[1]
|
||||
else:
|
||||
return None
|
||||
|
||||
# Clean up the path
|
||||
path = path.strip('/')
|
||||
if not path:
|
||||
return None
|
||||
|
||||
# Split the path and remove empty parts
|
||||
path_parts = [part for part in path.split('/') if part]
|
||||
|
||||
# We need at least 2 parts: group/repo
|
||||
if len(path_parts) < 2:
|
||||
return None
|
||||
|
||||
# Join all parts to form the full repository path
|
||||
return '/'.join(path_parts)
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def search_repositories(
|
||||
self,
|
||||
query: str,
|
||||
per_page: int = 30,
|
||||
sort: str = 'updated',
|
||||
order: str = 'desc',
|
||||
public: bool = False,
|
||||
) -> list[Repository]:
|
||||
if public:
|
||||
# When public=True, query is a GitLab URL that we need to parse
|
||||
repo_path = self._parse_gitlab_url(query)
|
||||
if not repo_path:
|
||||
return [] # Invalid URL format
|
||||
|
||||
repository = await self.get_repository_details_from_repo_name(repo_path)
|
||||
return [repository]
|
||||
|
||||
return await self.get_paginated_repos(1, per_page, sort, None, query)
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
self, query: str, per_page: int = 30, sort: str = 'updated', order: str = 'desc'
|
||||
) -> list[Repository]:
|
||||
url = f'{self.BASE_URL}/projects'
|
||||
order_by = {
|
||||
'pushed': 'last_activity_at',
|
||||
'updated': 'last_activity_at',
|
||||
'created': 'created_at',
|
||||
'full_name': 'name',
|
||||
}.get(sort, 'last_activity_at')
|
||||
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'order_by': order_by,
|
||||
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
|
||||
'membership': True, # Include projects user is a member of
|
||||
'search': query,
|
||||
'per_page': per_page,
|
||||
'order_by': 'last_activity_at',
|
||||
'sort': order,
|
||||
'visibility': 'public',
|
||||
}
|
||||
|
||||
if query:
|
||||
params['search'] = query
|
||||
params['search_namespaces'] = True
|
||||
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
next_link: str = headers.get('Link', '')
|
||||
response, _ = await self._make_request(url, params)
|
||||
repos = [
|
||||
self._parse_repository(repo, link_header=next_link) for repo in response
|
||||
Repository(
|
||||
id=str(repo.get('id')),
|
||||
full_name=repo.get('path_with_namespace'),
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=True,
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('namespace', {}).get('kind') == 'group'
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
for repo in response
|
||||
]
|
||||
|
||||
return repos
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitLab API
|
||||
all_repos: list[dict] = []
|
||||
@@ -397,7 +310,21 @@ class GitLabService(BaseGitService, GitService):
|
||||
|
||||
# Trim to MAX_REPOS if needed and convert to Repository objects
|
||||
all_repos = all_repos[:MAX_REPOS]
|
||||
return [self._parse_repository(repo) for repo in all_repos]
|
||||
return [
|
||||
Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=repo.get('visibility') == 'public',
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('namespace', {}).get('kind') == 'group'
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
for repo in all_repos
|
||||
]
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories.
|
||||
@@ -539,7 +466,18 @@ class GitLabService(BaseGitService, GitService):
|
||||
url = f'{self.BASE_URL}/projects/{encoded_name}'
|
||||
repo, _ = await self._make_request(url)
|
||||
|
||||
return self._parse_repository(repo)
|
||||
return Repository(
|
||||
id=str(repo.get('id')),
|
||||
full_name=repo.get('path_with_namespace'),
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=repo.get('visibility') == 'public',
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('namespace', {}).get('kind') == 'group'
|
||||
else OwnerType.USER
|
||||
),
|
||||
)
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository"""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Any, Coroutine, Literal, cast, overload
|
||||
from typing import Annotated, Any, Coroutine, Literal, overload
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
@@ -22,7 +22,6 @@ from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
MicroagentParseError,
|
||||
ProviderType,
|
||||
Repository,
|
||||
@@ -164,61 +163,16 @@ class ProviderHandler:
|
||||
service = self._get_service(provider)
|
||||
return await service.get_latest_token()
|
||||
|
||||
async def get_github_installations(self) -> list[str]:
|
||||
service = cast(InstallationsService, self._get_service(ProviderType.GITHUB))
|
||||
try:
|
||||
return await service.get_installations()
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get github installations {e}')
|
||||
|
||||
return []
|
||||
|
||||
async def get_bitbucket_workspaces(self) -> list[str]:
|
||||
service = cast(InstallationsService, self._get_service(ProviderType.BITBUCKET))
|
||||
try:
|
||||
return await service.get_installations()
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get bitbucket workspaces {e}')
|
||||
|
||||
return []
|
||||
|
||||
async def get_repositories(
|
||||
self,
|
||||
sort: str,
|
||||
app_mode: AppMode,
|
||||
selected_provider: ProviderType | None,
|
||||
page: int | None,
|
||||
per_page: int | None,
|
||||
installation_id: str | None,
|
||||
) -> list[Repository]:
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""
|
||||
Get repositories from providers
|
||||
"""
|
||||
|
||||
"""
|
||||
Get repositories from providers
|
||||
"""
|
||||
|
||||
if selected_provider:
|
||||
if not page or not per_page:
|
||||
logger.error('Failed to provider params for paginating repos')
|
||||
return []
|
||||
|
||||
service = self._get_service(selected_provider)
|
||||
try:
|
||||
return await service.get_paginated_repos(
|
||||
page, per_page, sort, installation_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error fetching repos from {selected_provider}: {e}')
|
||||
|
||||
return []
|
||||
|
||||
all_repos: list[Repository] = []
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self._get_service(provider)
|
||||
service_repos = await service.get_all_repositories(sort, app_mode)
|
||||
service_repos = await service.get_repositories(sort, app_mode)
|
||||
all_repos.extend(service_repos)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error fetching repos from {provider}: {e}')
|
||||
@@ -242,34 +196,17 @@ class ProviderHandler:
|
||||
|
||||
async def search_repositories(
|
||||
self,
|
||||
selected_provider: ProviderType | None,
|
||||
query: str,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
order: str,
|
||||
) -> list[Repository]:
|
||||
if selected_provider:
|
||||
service = self._get_service(selected_provider)
|
||||
public = self._is_repository_url(query, selected_provider)
|
||||
try:
|
||||
user_repos = await service.search_repositories(
|
||||
query, per_page, sort, order, public
|
||||
)
|
||||
return self._deduplicate_repositories(user_repos)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Error searching repos from select provider {selected_provider}: {e}'
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
all_repos: list[Repository] = []
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self._get_service(provider)
|
||||
public = self._is_repository_url(query, provider)
|
||||
service_repos = await service.search_repositories(
|
||||
query, per_page, sort, order, public
|
||||
query, per_page, sort, order
|
||||
)
|
||||
all_repos.extend(service_repos)
|
||||
except Exception as e:
|
||||
@@ -278,26 +215,6 @@ class ProviderHandler:
|
||||
|
||||
return all_repos
|
||||
|
||||
def _is_repository_url(self, query: str, provider: ProviderType) -> bool:
|
||||
"""Check if the query is a repository URL."""
|
||||
custom_host = self.provider_tokens[provider].host
|
||||
custom_host_exists = custom_host and custom_host in query
|
||||
default_host_exists = self.PROVIDER_DOMAINS[provider] in query
|
||||
|
||||
return query.startswith(('http://', 'https://')) and (
|
||||
custom_host_exists or default_host_exists
|
||||
)
|
||||
|
||||
def _deduplicate_repositories(self, repos: list[Repository]) -> list[Repository]:
|
||||
"""Remove duplicate repositories based on full_name."""
|
||||
seen = set()
|
||||
unique_repos = []
|
||||
for repo in repos:
|
||||
if repo.full_name not in seen:
|
||||
seen.add(repo.id)
|
||||
unique_repos.append(repo)
|
||||
return unique_repos
|
||||
|
||||
async def set_event_stream_secrets(
|
||||
self,
|
||||
event_stream: EventStream,
|
||||
|
||||
@@ -434,12 +434,6 @@ class BaseGitService(ABC):
|
||||
return microagents
|
||||
|
||||
|
||||
class InstallationsService(Protocol):
|
||||
async def get_installations(self) -> list[str]:
|
||||
"""Get installations for the service; repos live underneath these installations"""
|
||||
...
|
||||
|
||||
|
||||
class GitService(Protocol):
|
||||
"""Protocol defining the interface for Git service providers"""
|
||||
|
||||
@@ -464,26 +458,17 @@ class GitService(Protocol):
|
||||
...
|
||||
|
||||
async def search_repositories(
|
||||
self, query: str, per_page: int, sort: str, order: str, public: bool
|
||||
) -> list[Repository]:
|
||||
"""Search for public repositories"""
|
||||
...
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user"""
|
||||
...
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
query: str,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
order: str,
|
||||
) -> list[Repository]:
|
||||
"""Get a page of repositories for the authenticated user"""
|
||||
"""Search for repositories"""
|
||||
...
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user"""
|
||||
...
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
|
||||
@@ -18,7 +18,6 @@ from litellm import ModelInfo, PromptTokensDetails
|
||||
from litellm import completion as litellm_completion
|
||||
from litellm import completion_cost as litellm_completion_cost
|
||||
from litellm.exceptions import (
|
||||
APIConnectionError,
|
||||
RateLimitError,
|
||||
ServiceUnavailableError,
|
||||
)
|
||||
@@ -41,7 +40,6 @@ __all__ = ['LLM']
|
||||
|
||||
# tuple of exceptions to retry on
|
||||
LLM_RETRY_EXCEPTIONS: tuple[type[Exception], ...] = (
|
||||
APIConnectionError,
|
||||
RateLimitError,
|
||||
ServiceUnavailableError,
|
||||
litellm.Timeout,
|
||||
|
||||
@@ -104,17 +104,6 @@ async def create_mcp_clients(
|
||||
client = MCPClient()
|
||||
try:
|
||||
await client.connect_stdio(server)
|
||||
|
||||
# Log which tools this specific server provides
|
||||
tool_names = [tool.name for tool in client.tools]
|
||||
server_name = getattr(
|
||||
server, 'name', f'{server.command} {" ".join(server.args or [])}'
|
||||
)
|
||||
logger.debug(
|
||||
f'Successfully connected to MCP stdio server {server_name} - '
|
||||
f'provides {len(tool_names)} tools: {tool_names}'
|
||||
)
|
||||
|
||||
mcp_clients.append(client)
|
||||
except Exception as e:
|
||||
# Error is already logged and collected in client.connect_stdio()
|
||||
@@ -122,7 +111,6 @@ async def create_mcp_clients(
|
||||
continue
|
||||
|
||||
is_shttp = isinstance(server, MCPSHTTPServerConfig)
|
||||
|
||||
connection_type = 'SHTTP' if is_shttp else 'SSE'
|
||||
logger.info(
|
||||
f'Initializing MCP agent for {server} with {connection_type} connection...'
|
||||
@@ -132,13 +120,6 @@ async def create_mcp_clients(
|
||||
try:
|
||||
await client.connect_http(server, conversation_id=conversation_id)
|
||||
|
||||
# Log which tools this specific server provides
|
||||
tool_names = [tool.name for tool in client.tools]
|
||||
logger.debug(
|
||||
f'Successfully connected to MCP STTP server {server.url} - '
|
||||
f'provides {len(tool_names)} tools: {tool_names}'
|
||||
)
|
||||
|
||||
# Only add the client to the list after a successful connection
|
||||
mcp_clients.append(client)
|
||||
|
||||
@@ -174,7 +155,6 @@ async def fetch_mcp_tools_from_config(
|
||||
mcp_tools = []
|
||||
try:
|
||||
logger.debug(f'Creating MCP clients with config: {mcp_config}')
|
||||
|
||||
# Create clients - this will fetch tools but not maintain active connections
|
||||
mcp_clients = await create_mcp_clients(
|
||||
mcp_config.sse_servers,
|
||||
@@ -313,8 +293,9 @@ async def add_mcp_tools_to_agent(
|
||||
updated_mcp_config, use_stdio=isinstance(runtime, CLIRuntime)
|
||||
)
|
||||
|
||||
tool_names = [tool['function']['name'] for tool in mcp_tools]
|
||||
logger.info(f'Loaded {len(mcp_tools)} MCP tools: {tool_names}')
|
||||
logger.info(
|
||||
f'Loaded {len(mcp_tools)} MCP tools: {[tool["function"]["name"] for tool in mcp_tools]}'
|
||||
)
|
||||
|
||||
# Set the MCP tools on the agent
|
||||
agent.set_mcp_tools(mcp_tools)
|
||||
|
||||
@@ -363,21 +363,20 @@ class ActionExecutor:
|
||||
'$env:GIT_CONFIG = (Join-Path (Get-Location) ".git_config")'
|
||||
)
|
||||
else:
|
||||
INIT_COMMANDS.append(
|
||||
f'git config --file ./.git_config user.name "{self.git_user_name}"'
|
||||
# Linux/macOS, local
|
||||
base_git_config = (
|
||||
f'git config --file ./.git_config user.name "{self.git_user_name}" && '
|
||||
f'git config --file ./.git_config user.email "{self.git_user_email}" && '
|
||||
'export GIT_CONFIG=$(pwd)/.git_config'
|
||||
)
|
||||
INIT_COMMANDS.append(
|
||||
f'git config --file ./.git_config user.email "{self.git_user_email}"'
|
||||
)
|
||||
INIT_COMMANDS.append('export GIT_CONFIG=$(pwd)/.git_config')
|
||||
INIT_COMMANDS.append(base_git_config)
|
||||
else:
|
||||
# Non-local (implies Linux/macOS)
|
||||
INIT_COMMANDS.append(
|
||||
f'git config --global user.name "{self.git_user_name}"'
|
||||
)
|
||||
INIT_COMMANDS.append(
|
||||
base_git_config = (
|
||||
f'git config --global user.name "{self.git_user_name}" && '
|
||||
f'git config --global user.email "{self.git_user_email}"'
|
||||
)
|
||||
INIT_COMMANDS.append(base_git_config)
|
||||
|
||||
# Determine no-pager command
|
||||
if is_windows:
|
||||
@@ -676,9 +675,7 @@ class ActionExecutor:
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.warning('Starting Action Execution Server')
|
||||
logger.warning('Arguments passed to script:')
|
||||
for i, arg in enumerate(sys.argv):
|
||||
logger.warning(f'Argument {i}: {arg}')
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('port', type=int, help='Port to listen on')
|
||||
parser.add_argument('--working-dir', type=str, help='Working directory')
|
||||
|
||||
@@ -580,7 +580,7 @@ fi
|
||||
|
||||
if not files:
|
||||
self.log(
|
||||
'debug',
|
||||
'warning',
|
||||
f'No files found in {source_description} microagents directory: {microagents_dir}',
|
||||
)
|
||||
return loaded_microagents
|
||||
@@ -735,14 +735,27 @@ fi
|
||||
'Executing clone command for org-level repo',
|
||||
)
|
||||
|
||||
action = CmdRunAction(command=clone_cmd)
|
||||
# Make the organization microagents clone operation blocking with a 60-second timeout
|
||||
self.log(
|
||||
'info',
|
||||
f'Cloning org-level microagents from {org_openhands_repo} (blocking with 60s timeout)',
|
||||
)
|
||||
action = CmdRunAction(command=clone_cmd, blocking=True)
|
||||
action.set_hard_timeout(60) # 60 seconds timeout
|
||||
obs = self.run_action(action)
|
||||
|
||||
if isinstance(obs, CmdOutputObservation) and obs.exit_code == 0:
|
||||
self.log(
|
||||
'info',
|
||||
f'Successfully cloned org-level microagents from {org_openhands_repo}',
|
||||
)
|
||||
if isinstance(obs, CmdOutputObservation):
|
||||
if obs.exit_code == 0:
|
||||
self.log(
|
||||
'info',
|
||||
f'Successfully cloned org-level microagents from {org_openhands_repo}',
|
||||
)
|
||||
else:
|
||||
self.log(
|
||||
'warning',
|
||||
f'Failed to clone org-level microagents from {org_openhands_repo}: {obs.content}',
|
||||
)
|
||||
return loaded_microagents
|
||||
|
||||
# Load microagents from the org-level repo
|
||||
org_microagents_dir = org_repo_dir / 'microagents'
|
||||
|
||||
@@ -49,124 +49,72 @@ def init_user_and_working_directory(
|
||||
if username == os.getenv('USER') and username not in ['root', 'openhands']:
|
||||
return None
|
||||
|
||||
# Skip root since it is already created
|
||||
if username != 'root':
|
||||
# Check if the username already exists
|
||||
logger.info(f'Attempting to create user `{username}` with UID {user_id}.')
|
||||
existing_user_id = -1
|
||||
try:
|
||||
result = subprocess.run(
|
||||
f'id -u {username}', shell=True, check=True, capture_output=True
|
||||
)
|
||||
existing_user_id = int(result.stdout.decode().strip())
|
||||
|
||||
# The user ID already exists, skip setup
|
||||
if existing_user_id == user_id:
|
||||
logger.debug(
|
||||
f'User `{username}` already has the provided UID {user_id}. Skipping user setup.'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'User `{username}` already exists with UID {existing_user_id}. Skipping user setup.'
|
||||
)
|
||||
return existing_user_id
|
||||
return None
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Returncode 1 indicates, that the user does not exist yet
|
||||
if e.returncode == 1:
|
||||
logger.info(
|
||||
f'User `{username}` does not exist. Proceeding with user creation.'
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f'Error checking user `{username}`, skipping setup:\n{e}\n'
|
||||
)
|
||||
raise
|
||||
|
||||
# Add sudoer
|
||||
sudoer_line = r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"
|
||||
output = subprocess.run(sudoer_line, shell=True, capture_output=True)
|
||||
if output.returncode != 0:
|
||||
raise RuntimeError(f'Failed to add sudoer: {output.stderr.decode()}')
|
||||
logger.debug(f'Added sudoer successfully. Output: [{output.stdout.decode()}]')
|
||||
|
||||
command = (
|
||||
f'useradd -rm -d /home/{username} -s /bin/bash '
|
||||
f'-g root -G sudo -u {user_id} {username}'
|
||||
)
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
if output.returncode == 0:
|
||||
logger.debug(
|
||||
f'Added user `{username}` successfully with UID {user_id}. Output: [{output.stdout.decode()}]'
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f'Failed to create user `{username}` with UID {user_id}. Output: [{output.stderr.decode()}]'
|
||||
)
|
||||
|
||||
# First create the working directory, independent of the user
|
||||
logger.debug(f'Client working directory: {initial_cwd}')
|
||||
command = f'umask 002; mkdir -p {initial_cwd}'
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
out_str = output.stdout.decode()
|
||||
logger.debug(f'mkdir command result: returncode={output.returncode}, stdout=[{out_str}], stderr=[{output.stderr.decode()}]')
|
||||
|
||||
# Check current ownership before changing it
|
||||
check_cmd = f'ls -la {initial_cwd}'
|
||||
check_output = subprocess.run(check_cmd, shell=True, capture_output=True)
|
||||
logger.debug(f'Current ownership: {check_output.stdout.decode()}')
|
||||
|
||||
# Check if we're running as root
|
||||
whoami_output = subprocess.run('whoami', shell=True, capture_output=True)
|
||||
current_user = whoami_output.stdout.decode().strip()
|
||||
logger.debug(f'Current user: {current_user}')
|
||||
|
||||
# Use sudo only if not running as root
|
||||
sudo_prefix = '' if current_user == 'root' else 'sudo '
|
||||
|
||||
command = f'{sudo_prefix}chown -R {username}:{username} {initial_cwd}'
|
||||
logger.debug(f'Executing chown command: {command}')
|
||||
command = f'chown -R {username}:root {initial_cwd}'
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
out_str += output.stdout.decode()
|
||||
logger.debug(f'chown command result: returncode={output.returncode}, stdout=[{output.stdout.decode()}], stderr=[{output.stderr.decode()}]')
|
||||
if output.returncode != 0 or output.stderr:
|
||||
err_str = output.stderr.decode()
|
||||
logger.error(f'chown command failed: returncode={output.returncode}, stderr: {err_str}')
|
||||
out_str += f' [stderr: {err_str}]'
|
||||
|
||||
command = f'{sudo_prefix}chmod g+rw {initial_cwd}'
|
||||
logger.debug(f'Executing chmod command: {command}')
|
||||
command = f'chmod g+rw {initial_cwd}'
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
out_str += output.stdout.decode()
|
||||
logger.debug(f'chmod command result: returncode={output.returncode}, stdout=[{output.stdout.decode()}], stderr=[{output.stderr.decode()}]')
|
||||
if output.returncode != 0 or output.stderr:
|
||||
err_str = output.stderr.decode()
|
||||
logger.error(f'chmod command failed: returncode={output.returncode}, stderr: {err_str}')
|
||||
out_str += f' [stderr: {err_str}]'
|
||||
|
||||
# Verify final ownership
|
||||
check_cmd = f'ls -la {initial_cwd}'
|
||||
check_output = subprocess.run(check_cmd, shell=True, capture_output=True)
|
||||
final_ownership = check_output.stdout.decode()
|
||||
logger.debug(f'Final ownership: {final_ownership}')
|
||||
|
||||
# If chown failed and directory is still owned by root, try alternative approaches
|
||||
if 'root root' in final_ownership and username != 'root':
|
||||
logger.warning(f'Directory {initial_cwd} is still owned by root, trying alternative approaches')
|
||||
|
||||
# Try to make it writable for the user's group
|
||||
alt_command = f'{sudo_prefix}chmod -R g+rwx {initial_cwd}'
|
||||
logger.debug(f'Executing alternative chmod command: {alt_command}')
|
||||
alt_output = subprocess.run(alt_command, shell=True, capture_output=True)
|
||||
logger.debug(f'Alternative chmod result: returncode={alt_output.returncode}, stderr=[{alt_output.stderr.decode()}]')
|
||||
|
||||
# Try to add the user to the root group (as a last resort)
|
||||
if alt_output.returncode != 0:
|
||||
group_command = f'{sudo_prefix}usermod -aG root {username}'
|
||||
logger.debug(f'Executing usermod command: {group_command}')
|
||||
group_output = subprocess.run(group_command, shell=True, capture_output=True)
|
||||
logger.debug(f'Usermod result: returncode={group_output.returncode}, stderr=[{group_output.stderr.decode()}]')
|
||||
|
||||
logger.debug(f'Created working directory. Output: [{out_str}]')
|
||||
|
||||
# Skip root since it is already created
|
||||
if username == 'root':
|
||||
return None
|
||||
|
||||
# Check if the username already exists
|
||||
existing_user_id = -1
|
||||
try:
|
||||
result = subprocess.run(
|
||||
f'id -u {username}', shell=True, check=True, capture_output=True
|
||||
)
|
||||
existing_user_id = int(result.stdout.decode().strip())
|
||||
|
||||
# The user ID already exists, skip setup
|
||||
if existing_user_id == user_id:
|
||||
logger.debug(
|
||||
f'User `{username}` already has the provided UID {user_id}. Skipping user setup.'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'User `{username}` already exists with UID {existing_user_id}. Skipping user setup.'
|
||||
)
|
||||
return existing_user_id
|
||||
return None
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Returncode 1 indicates, that the user does not exist yet
|
||||
if e.returncode == 1:
|
||||
logger.debug(
|
||||
f'User `{username}` does not exist. Proceeding with user creation.'
|
||||
)
|
||||
else:
|
||||
logger.error(f'Error checking user `{username}`, skipping setup:\n{e}\n')
|
||||
raise
|
||||
|
||||
# Add sudoer
|
||||
sudoer_line = r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"
|
||||
output = subprocess.run(sudoer_line, shell=True, capture_output=True)
|
||||
if output.returncode != 0:
|
||||
raise RuntimeError(f'Failed to add sudoer: {output.stderr.decode()}')
|
||||
logger.debug(f'Added sudoer successfully. Output: [{output.stdout.decode()}]')
|
||||
|
||||
command = (
|
||||
f'useradd -rm -d /home/{username} -s /bin/bash '
|
||||
f'-g root -G sudo -u {user_id} {username}'
|
||||
)
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
if output.returncode == 0:
|
||||
logger.debug(
|
||||
f'Added user `{username}` successfully with UID {user_id}. Output: [{output.stdout.decode()}]'
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f'Failed to create user `{username}` with UID {user_id}. Output: [{output.stderr.decode()}]'
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -56,18 +56,24 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="/openhands/
|
||||
# Add /openhands/bin to PATH
|
||||
ENV PATH="/openhands/bin:${PATH}"
|
||||
|
||||
# Remove UID 1000 named pn or ubuntu, so the 'openhands' user can be created from ubuntu hosts
|
||||
RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
|
||||
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi)
|
||||
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /openhands && \
|
||||
mkdir -p /openhands/logs && \
|
||||
mkdir -p /openhands/poetry
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Define Docker installation macro
|
||||
{% macro install_docker() %}
|
||||
# Install Docker following official documentation
|
||||
# https://docs.docker.com/engine/install/ubuntu/
|
||||
# https://docs.docker.com/engine/install/debian/
|
||||
RUN \
|
||||
# Determine OS type and install accordingly
|
||||
if [[ "{{ base_image }}" == *"ubuntu"* ]]; then \
|
||||
if [[ "{{ base_image }}" == *"ubuntu"* ]] || [[ "{{ base_image }}" == *"mswebench"* ]]; then \
|
||||
# Handle Ubuntu (following https://docs.docker.com/engine/install/ubuntu/)
|
||||
# Add Docker's official GPG key
|
||||
apt-get update && \
|
||||
@@ -103,35 +109,6 @@ RUN \
|
||||
# Configure Docker daemon with MTU 1450 to prevent packet fragmentation issues
|
||||
RUN mkdir -p /etc/docker && \
|
||||
echo '{"mtu": 1450}' > /etc/docker/daemon.json
|
||||
|
||||
# Remove UID 1000 and GID 1000 users/groups that might conflict with openhands user
|
||||
RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
|
||||
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi) && \
|
||||
(if getent group 1000 | grep -q pn; then groupdel pn; fi) && \
|
||||
(if getent group 1000 | grep -q ubuntu; then groupdel ubuntu; fi)
|
||||
|
||||
# Create openhands group and user
|
||||
RUN groupadd -g 1000 openhands && \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash openhands && \
|
||||
usermod -aG sudo openhands && \
|
||||
echo 'openhands ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /openhands && \
|
||||
mkdir -p /openhands/logs && \
|
||||
mkdir -p /openhands/poetry && \
|
||||
mkdir -p /workspace && \
|
||||
mkdir -p /workspace/.openhands && \
|
||||
mkdir -p /home/openhands/.openhands && \
|
||||
chown -R openhands:openhands /openhands && \
|
||||
chown -R openhands:openhands /workspace && \
|
||||
chown -R openhands:openhands /home/openhands
|
||||
{% endmacro %}
|
||||
|
||||
# Install Docker only if not a swebench or mswebench image
|
||||
{% if not ('swebench' in base_image) and not ('mswebench' in base_image) %}
|
||||
{{ install_docker() }}
|
||||
{% endif %}
|
||||
# ================================================================
|
||||
|
||||
{% endmacro %}
|
||||
@@ -165,8 +142,7 @@ RUN if [ -z "${RELEASE_TAG}" ]; then \
|
||||
if [ -d "${OPENVSCODE_SERVER_ROOT}" ]; then rm -rf "${OPENVSCODE_SERVER_ROOT}"; fi && \
|
||||
mv ${RELEASE_TAG}-linux-${arch} ${OPENVSCODE_SERVER_ROOT} && \
|
||||
cp ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/openvscode-server ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/code && \
|
||||
rm -f ${RELEASE_TAG}-linux-${arch}.tar.gz && \
|
||||
chown -R openhands:openhands ${OPENVSCODE_SERVER_ROOT}
|
||||
rm -f ${RELEASE_TAG}-linux-${arch}.tar.gz
|
||||
|
||||
|
||||
|
||||
@@ -175,12 +151,10 @@ RUN if [ -z "${RELEASE_TAG}" ]; then \
|
||||
{% macro install_vscode_extensions() %}
|
||||
# Install our custom extension
|
||||
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world && \
|
||||
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/hello-world/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world/ && \
|
||||
chown -R openhands:openhands ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world
|
||||
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/hello-world/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world/
|
||||
|
||||
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor && \
|
||||
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/memory-monitor/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor/ && \
|
||||
chown -R openhands:openhands ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor
|
||||
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/memory-monitor/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor/
|
||||
|
||||
# Some extension dirs are removed because they trigger false positives in vulnerability scans.
|
||||
RUN rm -rf ${OPENVSCODE_SERVER_ROOT}/extensions/{handlebars,pug,json,diff,grunt,ini,npm}
|
||||
@@ -203,12 +177,9 @@ RUN \
|
||||
{% endif %}
|
||||
# Set environment variables
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry run python -c "import sys; print('OH_INTERPRETER_PATH=' + sys.executable)" >> /etc/environment && \
|
||||
# Set permissions and ownership
|
||||
# Set permissions
|
||||
chmod -R g+rws /openhands/poetry && \
|
||||
chown -R openhands:openhands /openhands/poetry && \
|
||||
mkdir -p /openhands/workspace && chmod -R g+rws,o+rw /openhands/workspace && \
|
||||
chown -R openhands:openhands /openhands/workspace && \
|
||||
chown -R openhands:openhands /openhands/micromamba && \
|
||||
# Clean up
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry cache clear --all . -n && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
|
||||
@@ -229,8 +200,7 @@ RUN \
|
||||
RUN mkdir -p /openhands/micromamba/bin && \
|
||||
/bin/bash -c "PREFIX_LOCATION=/openhands/micromamba BIN_FOLDER=/openhands/micromamba/bin INIT_YES=no CONDA_FORGE_YES=yes $(curl -L https://micro.mamba.pm/install.sh)" && \
|
||||
/openhands/micromamba/bin/micromamba config remove channels defaults && \
|
||||
/openhands/micromamba/bin/micromamba config list && \
|
||||
chown -R openhands:openhands /openhands/micromamba
|
||||
/openhands/micromamba/bin/micromamba config list
|
||||
|
||||
# Create the openhands virtual environment and install poetry and python
|
||||
RUN /openhands/micromamba/bin/micromamba create -n openhands -y && \
|
||||
@@ -241,12 +211,11 @@ RUN \
|
||||
if [ -d /openhands/code ]; then rm -rf /openhands/code; fi && \
|
||||
mkdir -p /openhands/code/openhands && \
|
||||
touch /openhands/code/openhands/__init__.py && \
|
||||
chown -R openhands:openhands /openhands/code && \
|
||||
# Set global git configuration to ensure proper author/committer information
|
||||
git config --global user.name "openhands" && \
|
||||
git config --global user.email "openhands@all-hands.dev"
|
||||
|
||||
COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
|
||||
{{ install_dependencies() }}
|
||||
|
||||
@@ -257,43 +226,14 @@ COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openh
|
||||
|
||||
{{ setup_vscode_server() }}
|
||||
|
||||
# ================================================================
|
||||
# Ensure openhands user and directories exist (for non-scratch builds)
|
||||
# ================================================================
|
||||
{% if not build_from_scratch %}
|
||||
# Remove UID 1000 and GID 1000 users/groups that might conflict with openhands user
|
||||
RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
|
||||
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi) && \
|
||||
(if getent group 1000 | grep -q pn; then groupdel pn; fi) && \
|
||||
(if getent group 1000 | grep -q ubuntu; then groupdel ubuntu; fi)
|
||||
|
||||
# Create openhands group and user if they don't exist
|
||||
RUN (getent group openhands || groupadd -g 1000 openhands) && \
|
||||
(getent passwd openhands || useradd -u 1000 -g 1000 -m -s /bin/bash openhands) && \
|
||||
usermod -aG sudo openhands && \
|
||||
echo 'openhands ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
|
||||
|
||||
# Create necessary directories and set ownership
|
||||
RUN mkdir -p /openhands && \
|
||||
mkdir -p /openhands/logs && \
|
||||
mkdir -p /openhands/poetry && \
|
||||
mkdir -p /workspace && \
|
||||
mkdir -p /workspace/.openhands && \
|
||||
mkdir -p /home/openhands/.openhands && \
|
||||
chown -R openhands:openhands /openhands && \
|
||||
chown -R openhands:openhands /workspace && \
|
||||
chown -R openhands:openhands /home/openhands
|
||||
{% endif %}
|
||||
|
||||
# ================================================================
|
||||
# Copy Project source files
|
||||
# ================================================================
|
||||
RUN if [ -d /openhands/code/openhands ]; then rm -rf /openhands/code/openhands; fi
|
||||
COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
|
||||
COPY --chown=openhands:openhands ./code/openhands /openhands/code/openhands
|
||||
RUN chmod a+rwx /openhands/code/openhands/__init__.py && \
|
||||
chown -R openhands:openhands /openhands/code
|
||||
COPY ./code/openhands /openhands/code/openhands
|
||||
RUN chmod a+rwx /openhands/code/openhands/__init__.py
|
||||
|
||||
|
||||
|
||||
@@ -307,12 +247,3 @@ RUN chmod a+rwx /openhands/code/openhands/__init__.py && \
|
||||
|
||||
# Install extra dependencies if specified
|
||||
{% if extra_deps %}RUN {{ extra_deps }} {% endif %}
|
||||
|
||||
# Copy entrypoint script and make it executable
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Set the entrypoint to run as root first, then switch to openhands
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
# Note: We don't set USER openhands here because the entrypoint handles the user switch
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# This entrypoint script runs as root to fix workspace ownership before switching to openhands user
|
||||
|
||||
echo "🔧 OpenHands Runtime Entrypoint - Fixing workspace ownership..."
|
||||
|
||||
# Check if /workspace exists and fix ownership
|
||||
if [ -d "/workspace" ]; then
|
||||
echo "📁 Found /workspace directory, checking ownership..."
|
||||
ls -la /workspace
|
||||
|
||||
# Fix ownership to openhands:openhands
|
||||
echo "🔧 Changing ownership to openhands:openhands..."
|
||||
chown -R openhands:openhands /workspace
|
||||
chmod -R g+rw /workspace
|
||||
|
||||
echo "✅ Ownership fixed:"
|
||||
ls -la /workspace
|
||||
else
|
||||
echo "⚠️ /workspace directory not found, will be created later"
|
||||
fi
|
||||
|
||||
# If arguments are provided, execute them as the openhands user
|
||||
if [ $# -gt 0 ]; then
|
||||
echo "🚀 Switching to openhands user and executing: $@"
|
||||
# Use exec to replace the current process and preserve all arguments
|
||||
exec su openhands -c "exec \"\$@\"" -- "$@"
|
||||
else
|
||||
echo "🚀 Switching to openhands user with bash shell"
|
||||
exec su - openhands
|
||||
fi
|
||||
@@ -331,12 +331,10 @@ class StandaloneConversationManager(ConversationManager):
|
||||
)
|
||||
await self.close_session(oldest_conversation_id)
|
||||
|
||||
config = self.config.model_copy(deep=True)
|
||||
|
||||
session = Session(
|
||||
sid=sid,
|
||||
file_store=self.file_store,
|
||||
config=config,
|
||||
config=self.config,
|
||||
sio=self.sio,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ from openhands.integrations.provider import (
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
Branch,
|
||||
ProviderType,
|
||||
Repository,
|
||||
SuggestedTask,
|
||||
UnknownException,
|
||||
@@ -34,43 +33,9 @@ from openhands.server.user_auth import (
|
||||
app = APIRouter(prefix='/api/user', dependencies=get_dependencies())
|
||||
|
||||
|
||||
@app.get('/installations', response_model=list[str])
|
||||
async def get_user_installations(
|
||||
provider: ProviderType,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
if provider == ProviderType.GITHUB:
|
||||
return await client.get_github_installations()
|
||||
elif provider == ProviderType.BITBUCKET:
|
||||
return await client.get_bitbucket_workspaces()
|
||||
else:
|
||||
return JSONResponse(
|
||||
content=f"Provider {provider} doesn't support installations",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content='Git provider token required. (such as GitHub).',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
@app.get('/repositories', response_model=list[Repository])
|
||||
async def get_user_repositories(
|
||||
sort: str = 'pushed',
|
||||
selected_provider: ProviderType | None = None,
|
||||
page: int | None = None,
|
||||
per_page: int | None = None,
|
||||
installation_id: str | None = None,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
@@ -83,14 +48,7 @@ async def get_user_repositories(
|
||||
)
|
||||
|
||||
try:
|
||||
return await client.get_repositories(
|
||||
sort,
|
||||
server_config.app_mode,
|
||||
selected_provider,
|
||||
page,
|
||||
per_page,
|
||||
installation_id,
|
||||
)
|
||||
return await client.get_repositories(sort, server_config.app_mode)
|
||||
|
||||
except AuthenticationError as e:
|
||||
logger.info(
|
||||
@@ -161,20 +119,17 @@ async def search_repositories(
|
||||
per_page: int = 5,
|
||||
sort: str = 'stars',
|
||||
order: str = 'desc',
|
||||
selected_provider: ProviderType | None = None,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> list[Repository] | JSONResponse:
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
provider_tokens=provider_tokens, external_auth_token=access_token
|
||||
)
|
||||
try:
|
||||
repos: list[Repository] = await client.search_repositories(
|
||||
selected_provider, query, per_page, sort, order
|
||||
query, per_page, sort, order
|
||||
)
|
||||
return repos
|
||||
|
||||
@@ -191,10 +146,10 @@ async def search_repositories(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Returning 401 Unauthorized - Git provider token required for user_id: {user_id}'
|
||||
f'Returning 401 Unauthorized - GitHub token required for user_id: {user_id}'
|
||||
)
|
||||
return JSONResponse(
|
||||
content='Git provider token required.',
|
||||
content='GitHub token required.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from openhands.core.config.condenser_config import (
|
||||
ConversationWindowCondenserConfig,
|
||||
LLMSummarizingCondenserConfig,
|
||||
)
|
||||
from openhands.core.config.mcp_config import OpenHandsMCPConfigImpl
|
||||
from openhands.core.config.mcp_config import MCPConfig, OpenHandsMCPConfigImpl
|
||||
from openhands.core.exceptions import MicroagentValidationError
|
||||
from openhands.core.logger import OpenHandsLoggerAdapter
|
||||
from openhands.core.schema import AgentState
|
||||
@@ -122,12 +122,6 @@ class Session:
|
||||
or settings.sandbox_runtime_container_image
|
||||
else self.config.sandbox.runtime_container_image
|
||||
)
|
||||
|
||||
# Set Git user configuration if provided in settings
|
||||
if hasattr(settings, 'git_user_name') and settings.git_user_name:
|
||||
self.config.git_user_name = settings.git_user_name
|
||||
if hasattr(settings, 'git_user_email') and settings.git_user_email:
|
||||
self.config.git_user_email = settings.git_user_email
|
||||
max_iterations = settings.max_iterations or self.config.max_iterations
|
||||
|
||||
# Prioritize settings over config for max_budget_per_task
|
||||
@@ -149,8 +143,8 @@ class Session:
|
||||
self.config.sandbox.api_key = settings.sandbox_api_key.get_secret_value()
|
||||
|
||||
# NOTE: this need to happen AFTER the config is updated with the search_api_key
|
||||
self.logger.debug(
|
||||
f'MCP configuration before setup - self.config.mcp_config: {self.config.mcp}'
|
||||
self.config.mcp = settings.mcp_config or MCPConfig(
|
||||
sse_servers=[], stdio_servers=[]
|
||||
)
|
||||
# Add OpenHands' MCP server by default
|
||||
openhands_mcp_server, openhands_mcp_stdio_servers = (
|
||||
@@ -158,17 +152,10 @@ class Session:
|
||||
self.config.mcp_host, self.config, self.user_id
|
||||
)
|
||||
)
|
||||
|
||||
if openhands_mcp_server:
|
||||
self.config.mcp.shttp_servers.append(openhands_mcp_server)
|
||||
self.logger.debug('Added default MCP HTTP server to config')
|
||||
|
||||
self.config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
|
||||
|
||||
self.logger.debug(
|
||||
f'MCP configuration after setup - self.config.mcp: {self.config.mcp}'
|
||||
)
|
||||
|
||||
# TODO: override other LLM config & agent config groups (#2075)
|
||||
|
||||
llm = self._create_llm(agent_cls)
|
||||
@@ -276,7 +263,6 @@ class Session:
|
||||
|
||||
async def _on_event(self, event: Event) -> None:
|
||||
"""Callback function for events that mainly come from the agent.
|
||||
|
||||
Event is the base class for any agent action and observation.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -45,8 +45,6 @@ class Settings(BaseModel):
|
||||
max_budget_per_task: float | None = None
|
||||
email: str | None = None
|
||||
email_verified: bool | None = None
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
|
||||
model_config = ConfigDict(
|
||||
validate_assignment=True,
|
||||
|
||||
@@ -603,7 +603,7 @@ async def test_reset_with_pending_action_no_observation(mock_agent, mock_event_s
|
||||
assert isinstance(error_obs, ErrorObservation)
|
||||
assert (
|
||||
error_obs.content
|
||||
== 'The action has not been executed due to a runtime error. The runtime system may have crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.'
|
||||
== 'The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.'
|
||||
)
|
||||
assert error_obs.tool_call_metadata == pending_action.tool_call_metadata
|
||||
assert error_obs._cause == pending_action.id
|
||||
@@ -617,53 +617,6 @@ async def test_reset_with_pending_action_no_observation(mock_agent, mock_event_s
|
||||
await controller.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_with_pending_action_stopped_state(mock_agent, mock_event_stream):
|
||||
"""Test reset() when there's a pending action and agent state is STOPPED."""
|
||||
controller = AgentController(
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
iteration_delta=10,
|
||||
sid='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
|
||||
mock_event_stream.add_event.assert_called_once() # add SystemMessageAction
|
||||
mock_event_stream.add_event.reset_mock()
|
||||
|
||||
# Create a pending action with tool call metadata
|
||||
pending_action = CmdRunAction(command='test')
|
||||
pending_action.tool_call_metadata = {
|
||||
'function': 'test_function',
|
||||
'args': {'arg1': 'value1'},
|
||||
}
|
||||
controller._pending_action = pending_action
|
||||
|
||||
# Set agent state to STOPPED
|
||||
controller.state.agent_state = AgentState.STOPPED
|
||||
|
||||
# Call reset
|
||||
controller._reset()
|
||||
|
||||
# Verify that an ErrorObservation was added to the event stream
|
||||
mock_event_stream.add_event.assert_called_once()
|
||||
args, kwargs = mock_event_stream.add_event.call_args
|
||||
error_obs, source = args
|
||||
assert isinstance(error_obs, ErrorObservation)
|
||||
assert error_obs.content == 'Stop button pressed. The action has not been executed.'
|
||||
assert error_obs.tool_call_metadata == pending_action.tool_call_metadata
|
||||
assert error_obs._cause == pending_action.id
|
||||
assert source == EventSource.AGENT
|
||||
|
||||
# Verify that pending action was reset
|
||||
assert controller._pending_action is None
|
||||
|
||||
# Verify that agent.reset() was called
|
||||
mock_agent.reset.assert_called_once()
|
||||
await controller.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_with_pending_action_existing_observation(
|
||||
mock_agent, mock_event_stream
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from litellm.exceptions import APIConnectionError
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_config():
|
||||
return LLMConfig(
|
||||
model='gpt-4o',
|
||||
api_key='test_key',
|
||||
num_retries=2,
|
||||
retry_min_wait=1,
|
||||
retry_max_wait=2,
|
||||
)
|
||||
|
||||
|
||||
@patch('openhands.llm.llm.litellm_completion')
|
||||
def test_completion_retries_api_connection_error(
|
||||
mock_litellm_completion, default_config
|
||||
):
|
||||
"""Test that APIConnectionError is properly retried."""
|
||||
# Mock the litellm_completion to first raise an APIConnectionError, then return a successful response
|
||||
mock_litellm_completion.side_effect = [
|
||||
APIConnectionError(
|
||||
message='API connection error',
|
||||
llm_provider='test_provider',
|
||||
model='test_model',
|
||||
),
|
||||
{'choices': [{'message': {'content': 'Retry successful'}}]},
|
||||
]
|
||||
|
||||
# Create an LLM instance and call completion
|
||||
llm = LLM(config=default_config)
|
||||
response = llm.completion(
|
||||
messages=[{'role': 'user', 'content': 'Hello!'}],
|
||||
stream=False,
|
||||
)
|
||||
|
||||
# Verify that the retry was successful
|
||||
assert response['choices'][0]['message']['content'] == 'Retry successful'
|
||||
assert mock_litellm_completion.call_count == 2 # Initial call + 1 retry
|
||||
|
||||
|
||||
@patch('openhands.llm.llm.litellm_completion')
|
||||
def test_completion_max_retries_api_connection_error(
|
||||
mock_litellm_completion, default_config
|
||||
):
|
||||
"""Test that APIConnectionError respects max retries."""
|
||||
# Mock the litellm_completion to raise APIConnectionError multiple times
|
||||
mock_litellm_completion.side_effect = [
|
||||
APIConnectionError(
|
||||
message='API connection error 1',
|
||||
llm_provider='test_provider',
|
||||
model='test_model',
|
||||
),
|
||||
APIConnectionError(
|
||||
message='API connection error 2',
|
||||
llm_provider='test_provider',
|
||||
model='test_model',
|
||||
),
|
||||
APIConnectionError(
|
||||
message='API connection error 3',
|
||||
llm_provider='test_provider',
|
||||
model='test_model',
|
||||
),
|
||||
]
|
||||
|
||||
# Create an LLM instance and call completion
|
||||
llm = LLM(config=default_config)
|
||||
|
||||
# The completion should raise an APIConnectionError after exhausting all retries
|
||||
with pytest.raises(APIConnectionError) as excinfo:
|
||||
llm.completion(
|
||||
messages=[{'role': 'user', 'content': 'Hello!'}],
|
||||
stream=False,
|
||||
)
|
||||
|
||||
# Verify that the correct number of retries were attempted
|
||||
# The actual behavior is that it tries the initial call + num_retries (not +1)
|
||||
assert mock_litellm_completion.call_count == default_config.num_retries
|
||||
|
||||
# The exception doesn't contain retry information in the current implementation
|
||||
# Just verify that we got an APIConnectionError
|
||||
assert 'API connection error' in str(excinfo.value)
|
||||
@@ -450,7 +450,7 @@ async def test_bitbucket_sort_parameter_mapping():
|
||||
]
|
||||
|
||||
# Call get_repositories with sort='pushed'
|
||||
await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify that the second call used 'updated_on' instead of 'pushed'
|
||||
assert mock_request.call_count == 2
|
||||
@@ -520,7 +520,7 @@ async def test_bitbucket_pagination():
|
||||
]
|
||||
|
||||
# Call get_repositories
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify that all three requests were made (workspaces + 2 pages of repos)
|
||||
assert mock_request.call_count == 3
|
||||
@@ -619,14 +619,14 @@ async def test_bitbucket_get_repositories_with_user_owner_type():
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_repos]
|
||||
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
|
||||
# Verify owner_type is correctly set for user repositories (private workspace)
|
||||
for repo in repositories:
|
||||
assert repo.owner_type == OwnerType.ORGANIZATION
|
||||
assert repo.owner_type == OwnerType.USER
|
||||
assert isinstance(repo, Repository)
|
||||
assert repo.git_provider == ServiceProviderType.BITBUCKET
|
||||
|
||||
@@ -658,7 +658,7 @@ async def test_bitbucket_get_repositories_with_organization_owner_type():
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_repos]
|
||||
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@@ -706,7 +706,7 @@ async def test_bitbucket_get_repositories_mixed_owner_types():
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_user_repos, mock_org_repos]
|
||||
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got repositories from both workspaces
|
||||
assert len(repositories) == 2
|
||||
@@ -715,10 +715,44 @@ async def test_bitbucket_get_repositories_mixed_owner_types():
|
||||
user_repo = next(repo for repo in repositories if 'user-repo' in repo.full_name)
|
||||
org_repo = next(repo for repo in repositories if 'org-repo' in repo.full_name)
|
||||
|
||||
assert user_repo.owner_type == OwnerType.ORGANIZATION
|
||||
assert user_repo.owner_type == OwnerType.USER
|
||||
assert org_repo.owner_type == OwnerType.ORGANIZATION
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bitbucket_get_repositories_owner_type_fallback():
|
||||
"""Test that owner_type defaults to USER when workspace is private."""
|
||||
service = BitBucketService(token=SecretStr('test-token'))
|
||||
|
||||
# Mock repository data with private workspace (should default to USER)
|
||||
mock_workspaces = [{'slug': 'test-user', 'name': 'Test User'}]
|
||||
mock_repos = [
|
||||
{
|
||||
'uuid': 'repo-1',
|
||||
'slug': 'user-repo',
|
||||
'workspace': {'slug': 'test-user', 'is_private': True}, # Private workspace
|
||||
'is_private': False,
|
||||
'updated_on': '2023-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
'uuid': 'repo-2',
|
||||
'slug': 'another-user-repo',
|
||||
'workspace': {'slug': 'test-user', 'is_private': True}, # Private workspace
|
||||
'is_private': True,
|
||||
'updated_on': '2023-01-02T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_repos]
|
||||
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify all repositories default to USER owner_type for private workspaces
|
||||
for repo in repositories:
|
||||
assert repo.owner_type == OwnerType.USER
|
||||
|
||||
|
||||
# Setup.py Bitbucket Token Tests
|
||||
@patch('openhands.core.setup.call_async_from_sync')
|
||||
@patch('openhands.core.setup.get_file_store')
|
||||
|
||||
@@ -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,281 +162,8 @@ async def test_gitlab_get_repositories_owner_type_fallback():
|
||||
# Mock the pagination response
|
||||
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
|
||||
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify all repositories default to USER owner_type
|
||||
for repo in repositories:
|
||||
assert repo.owner_type == OwnerType.USER
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_uses_membership_and_min_access_level():
|
||||
"""Test that search_repositories uses membership and min_access_level for non-public searches."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
# Mock repository data
|
||||
mock_repos = [
|
||||
{
|
||||
'id': 123,
|
||||
'path_with_namespace': 'test-user/search-repo1',
|
||||
'star_count': 10,
|
||||
'visibility': 'private',
|
||||
'namespace': {'kind': 'user'},
|
||||
},
|
||||
{
|
||||
'id': 456,
|
||||
'path_with_namespace': 'test-org/search-repo2',
|
||||
'star_count': 25,
|
||||
'visibility': 'private',
|
||||
'namespace': {'kind': 'group'},
|
||||
},
|
||||
]
|
||||
|
||||
with patch.object(service, '_make_request') as mock_request:
|
||||
mock_request.return_value = (mock_repos, {})
|
||||
|
||||
# Test non-public search (should use membership and min_access_level)
|
||||
repositories = await service.search_repositories(
|
||||
query='test-query', per_page=30, sort='updated', order='desc', public=False
|
||||
)
|
||||
|
||||
# Verify the request was made with correct parameters
|
||||
mock_request.assert_called_once()
|
||||
call_args = mock_request.call_args
|
||||
url = call_args[0][0]
|
||||
params = call_args[0][1] # params is the second positional argument
|
||||
|
||||
assert url == f'{service.BASE_URL}/projects'
|
||||
assert params['search'] == 'test-query'
|
||||
assert params['per_page'] == '30' # GitLab service converts to string
|
||||
assert params['order_by'] == 'last_activity_at'
|
||||
assert params['sort'] == 'desc'
|
||||
assert params['membership'] is True
|
||||
assert params['search_namespaces'] is True # Added by implementation
|
||||
assert 'min_access_level' not in params # Not set by current implementation
|
||||
assert 'owned' not in params
|
||||
assert 'visibility' not in params
|
||||
|
||||
# Verify we got the expected repositories
|
||||
assert len(repositories) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_public_search_legacy():
|
||||
"""Test that search_repositories returns empty list for non-URL queries when public=True."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
with patch.object(service, '_make_request') as mock_request:
|
||||
# Test public search with non-URL query (should return empty list)
|
||||
repositories = await service.search_repositories(
|
||||
query='public-query', per_page=20, sort='updated', order='asc', public=True
|
||||
)
|
||||
|
||||
# Verify no request was made since it's not a valid URL
|
||||
mock_request.assert_not_called()
|
||||
|
||||
# Verify we got empty list
|
||||
assert len(repositories) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_url_parsing():
|
||||
"""Test that search_repositories correctly parses GitLab URLs when public=True."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
# Test URL parsing method directly
|
||||
assert service._parse_gitlab_url('https://gitlab.com/group/repo') == 'group/repo'
|
||||
assert (
|
||||
service._parse_gitlab_url('https://gitlab.com/group/subgroup/repo')
|
||||
== 'group/subgroup/repo'
|
||||
)
|
||||
assert (
|
||||
service._parse_gitlab_url('https://gitlab.example.com/org/team/project')
|
||||
== 'org/team/project'
|
||||
)
|
||||
assert service._parse_gitlab_url('https://gitlab.com/group/repo/') == 'group/repo'
|
||||
assert (
|
||||
service._parse_gitlab_url('https://gitlab.com/group/') is None
|
||||
) # Missing repo
|
||||
assert service._parse_gitlab_url('https://gitlab.com/') is None # Empty path
|
||||
assert service._parse_gitlab_url('invalid-url') is None # Invalid URL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_public_url_lookup():
|
||||
"""Test that search_repositories looks up specific repository when public=True."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
# Mock repository data
|
||||
|
||||
with patch.object(
|
||||
service, 'get_repository_details_from_repo_name'
|
||||
) as mock_get_repo:
|
||||
mock_get_repo.return_value = Repository(
|
||||
id='123',
|
||||
full_name='group/repo',
|
||||
stargazers_count=50,
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=True,
|
||||
owner_type=OwnerType.ORGANIZATION,
|
||||
)
|
||||
|
||||
# Test with valid GitLab URL
|
||||
repositories = await service.search_repositories(
|
||||
query='https://gitlab.com/group/repo', public=True
|
||||
)
|
||||
|
||||
# Verify the repository lookup was called with correct path
|
||||
mock_get_repo.assert_called_once_with('group/repo')
|
||||
|
||||
# Verify we got the expected repository
|
||||
assert len(repositories) == 1
|
||||
assert repositories[0].full_name == 'group/repo'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_public_url_lookup_with_subgroup():
|
||||
"""Test that search_repositories handles subgroups correctly when public=True."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
with patch.object(
|
||||
service, 'get_repository_details_from_repo_name'
|
||||
) as mock_get_repo:
|
||||
mock_get_repo.return_value = Repository(
|
||||
id='456',
|
||||
full_name='group/subgroup/repo',
|
||||
stargazers_count=25,
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=True,
|
||||
owner_type=OwnerType.ORGANIZATION,
|
||||
)
|
||||
|
||||
# Test with GitLab URL containing subgroup
|
||||
repositories = await service.search_repositories(
|
||||
query='https://gitlab.example.com/group/subgroup/repo', public=True
|
||||
)
|
||||
|
||||
# Verify the repository lookup was called with correct path
|
||||
mock_get_repo.assert_called_once_with('group/subgroup/repo')
|
||||
|
||||
# Verify we got the expected repository
|
||||
assert len(repositories) == 1
|
||||
assert repositories[0].full_name == 'group/subgroup/repo'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_public_url_not_found():
|
||||
"""Test that search_repositories returns empty list when repository doesn't exist."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
with patch.object(
|
||||
service, 'get_repository_details_from_repo_name'
|
||||
) as mock_get_repo:
|
||||
# Simulate repository not found
|
||||
mock_get_repo.side_effect = Exception('Repository not found')
|
||||
|
||||
# Test with valid GitLab URL but non-existent repository
|
||||
# The current implementation doesn't catch exceptions, so we expect it to be raised
|
||||
with pytest.raises(Exception, match='Repository not found'):
|
||||
await service.search_repositories(
|
||||
query='https://gitlab.com/nonexistent/repo', public=True
|
||||
)
|
||||
|
||||
# Verify the repository lookup was attempted
|
||||
mock_get_repo.assert_called_once_with('nonexistent/repo')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_public_invalid_url():
|
||||
"""Test that search_repositories returns empty list for invalid URLs."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
with patch.object(
|
||||
service, 'get_repository_details_from_repo_name'
|
||||
) as mock_get_repo:
|
||||
# Test with invalid URL
|
||||
repositories = await service.search_repositories(
|
||||
query='invalid-url', public=True
|
||||
)
|
||||
|
||||
# Verify no repository lookup was attempted
|
||||
mock_get_repo.assert_not_called()
|
||||
|
||||
# Verify we got empty list
|
||||
assert len(repositories) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_formats_search_query():
|
||||
"""Test that search_repositories properly formats search queries with multiple terms."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
# Mock repository data
|
||||
mock_repos = [
|
||||
{
|
||||
'id': 123,
|
||||
'path_with_namespace': 'group/repo',
|
||||
'star_count': 50,
|
||||
'visibility': 'private',
|
||||
'namespace': {'kind': 'group'},
|
||||
},
|
||||
]
|
||||
|
||||
with patch.object(service, '_make_request') as mock_request:
|
||||
mock_request.return_value = (mock_repos, {})
|
||||
|
||||
# Test search with multiple terms (should format with + separator)
|
||||
repositories = await service.search_repositories(
|
||||
query='my project name', public=False
|
||||
)
|
||||
|
||||
# Verify the request was made with correct parameters
|
||||
mock_request.assert_called_once()
|
||||
call_args = mock_request.call_args
|
||||
url = call_args[0][0]
|
||||
params = call_args[0][1]
|
||||
|
||||
assert url == f'{service.BASE_URL}/projects'
|
||||
assert (
|
||||
params['search'] == 'my project name'
|
||||
) # Current implementation doesn't format spaces
|
||||
assert params['membership'] is True
|
||||
assert params['search_namespaces'] is True # Added by implementation
|
||||
|
||||
# Verify we got the expected repositories
|
||||
assert len(repositories) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_search_repositories_single_term_query():
|
||||
"""Test that search_repositories handles single term queries correctly."""
|
||||
service = GitLabService(token=SecretStr('test-token'))
|
||||
|
||||
# Mock repository data
|
||||
mock_repos = [
|
||||
{
|
||||
'id': 456,
|
||||
'path_with_namespace': 'user/single-repo',
|
||||
'star_count': 25,
|
||||
'visibility': 'private',
|
||||
'namespace': {'kind': 'user'},
|
||||
},
|
||||
]
|
||||
|
||||
with patch.object(service, '_make_request') as mock_request:
|
||||
mock_request.return_value = (mock_repos, {})
|
||||
|
||||
# Test search with single term (should remain unchanged)
|
||||
repositories = await service.search_repositories(
|
||||
query='singleterm', public=False
|
||||
)
|
||||
|
||||
# Verify the request was made with correct parameters
|
||||
mock_request.assert_called_once()
|
||||
call_args = mock_request.call_args
|
||||
params = call_args[0][1]
|
||||
|
||||
assert params['search'] == 'singleterm' # No change for single term
|
||||
|
||||
# Verify we got the expected repositories
|
||||
assert len(repositories) == 1
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.core.config import OpenHandsConfig, load_from_env
|
||||
from openhands.core.config.mcp_config import (
|
||||
MCPConfig,
|
||||
MCPSHTTPServerConfig,
|
||||
MCPSSEServerConfig,
|
||||
MCPStdioServerConfig,
|
||||
)
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
from openhands.server.session.session import Session
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
|
||||
|
||||
def test_valid_sse_config():
|
||||
@@ -279,174 +270,3 @@ def test_mcp_stdio_server_args_parsing_invalid_quotes():
|
||||
name='test-server', command='python', args='--config "unmatched quote'
|
||||
)
|
||||
assert 'Invalid argument format' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_env_var_mcp_shttp_server_config(monkeypatch):
|
||||
"""Test creating MCPSHTTPServerConfig from environment variables."""
|
||||
# Set environment variables for MCP HTTP server
|
||||
monkeypatch.setenv(
|
||||
'MCP_SHTTP_SERVERS',
|
||||
'[{"url": "http://env-server:8080", "api_key": "env-api-key"}]',
|
||||
)
|
||||
|
||||
# Create a config object
|
||||
config = OpenHandsConfig()
|
||||
|
||||
# Load from environment
|
||||
load_from_env(config, os.environ)
|
||||
|
||||
# Convert dictionary servers to proper server config objects by creating a new MCPConfig
|
||||
# This triggers the model validator which automatically converts dict servers to proper objects
|
||||
config.mcp = MCPConfig(
|
||||
sse_servers=config.mcp.sse_servers,
|
||||
stdio_servers=config.mcp.stdio_servers,
|
||||
shttp_servers=config.mcp.shttp_servers,
|
||||
)
|
||||
|
||||
# Check that the HTTP server was added
|
||||
assert len(config.mcp.shttp_servers) == 1
|
||||
|
||||
# Access the first server
|
||||
server = config.mcp.shttp_servers[0]
|
||||
|
||||
# Verify it's a proper server config object
|
||||
assert isinstance(server, MCPSHTTPServerConfig)
|
||||
assert server.url == 'http://env-server:8080'
|
||||
assert server.api_key == 'env-api-key'
|
||||
|
||||
# Now let's create a proper MCPConfig with the values from the environment
|
||||
mcp_config = MCPConfig(shttp_servers=config.mcp.shttp_servers)
|
||||
|
||||
# Verify that the MCPSHTTPServerConfig objects are created correctly
|
||||
assert len(mcp_config.shttp_servers) == 1
|
||||
assert isinstance(mcp_config.shttp_servers[0], MCPSHTTPServerConfig)
|
||||
assert mcp_config.shttp_servers[0].url == 'http://env-server:8080'
|
||||
assert mcp_config.shttp_servers[0].api_key == 'env-api-key'
|
||||
|
||||
|
||||
def test_env_var_mcp_shttp_server_config_with_toml(monkeypatch, tmp_path):
|
||||
"""Test creating MCPSHTTPServerConfig from environment variables with TOML config."""
|
||||
# Create a TOML file with some MCP configuration
|
||||
toml_file = tmp_path / 'config.toml'
|
||||
with open(toml_file, 'w', encoding='utf-8') as f:
|
||||
f.write("""
|
||||
[mcp]
|
||||
sse_servers = ["http://toml-server:8080"]
|
||||
shttp_servers = [
|
||||
{ url = "http://toml-http-server:8080", api_key = "toml-api-key" }
|
||||
]
|
||||
""")
|
||||
|
||||
# Set environment variables for MCP HTTP server to override TOML
|
||||
monkeypatch.setenv(
|
||||
'MCP_SHTTP_SERVERS',
|
||||
'[{"url": "http://env-server:8080", "api_key": "env-api-key"}]',
|
||||
)
|
||||
|
||||
# Create a config object
|
||||
config = OpenHandsConfig()
|
||||
|
||||
# Load from TOML first
|
||||
from openhands.core.config import load_from_toml
|
||||
|
||||
load_from_toml(config, str(toml_file))
|
||||
|
||||
# Verify TOML values were loaded
|
||||
assert len(config.mcp.shttp_servers) == 1
|
||||
assert isinstance(config.mcp.shttp_servers[0], MCPSHTTPServerConfig)
|
||||
assert config.mcp.shttp_servers[0].url == 'http://toml-http-server:8080'
|
||||
assert config.mcp.shttp_servers[0].api_key == 'toml-api-key'
|
||||
|
||||
# Now load from environment, which should override TOML
|
||||
load_from_env(config, os.environ)
|
||||
|
||||
# Check that the environment values override the TOML values
|
||||
assert len(config.mcp.shttp_servers) == 1
|
||||
|
||||
# The values should now be from the environment
|
||||
server = config.mcp.shttp_servers[0]
|
||||
assert isinstance(server, MCPSHTTPServerConfig)
|
||||
assert server.url == 'http://env-server:8080'
|
||||
assert server.api_key == 'env-api-key'
|
||||
|
||||
|
||||
def test_env_var_mcp_shttp_servers_with_python_str_representation(monkeypatch):
|
||||
"""Test creating MCPSHTTPServerConfig from environment variables using Python string representation."""
|
||||
# Create a Python list of dictionaries
|
||||
mcp_shttp_servers = [
|
||||
{'url': 'https://example.com/mcp/mcp', 'api_key': 'test-api-key'}
|
||||
]
|
||||
|
||||
# Set environment variable with the string representation of the Python list
|
||||
monkeypatch.setenv('MCP_SHTTP_SERVERS', str(mcp_shttp_servers))
|
||||
|
||||
# Create a config object
|
||||
config = OpenHandsConfig()
|
||||
|
||||
# Load from environment
|
||||
load_from_env(config, os.environ)
|
||||
|
||||
# Check that the HTTP server was added
|
||||
assert len(config.mcp.shttp_servers) == 1
|
||||
|
||||
# Access the first server
|
||||
server = config.mcp.shttp_servers[0]
|
||||
|
||||
# Check that it's a dict with the expected keys
|
||||
assert isinstance(server, MCPSHTTPServerConfig)
|
||||
assert server.url == 'https://example.com/mcp/mcp'
|
||||
assert server.api_key == 'test-api-key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_preserves_env_mcp_config(monkeypatch):
|
||||
"""Test that Session preserves MCP configuration from environment variables."""
|
||||
# Set environment variables for MCP HTTP server
|
||||
monkeypatch.setenv(
|
||||
'MCP_SHTTP_SERVERS',
|
||||
'[{"url": "http://env-server:8080", "api_key": "env-api-key"}]',
|
||||
)
|
||||
|
||||
# Also set MCP_HOST to prevent the default server from being added
|
||||
monkeypatch.setenv('MCP_HOST', 'dummy')
|
||||
|
||||
# Create a config object and load from environment
|
||||
config = OpenHandsConfig()
|
||||
load_from_env(config, os.environ)
|
||||
|
||||
# Verify the environment variables were loaded into the config
|
||||
assert config.mcp_host == 'dummy'
|
||||
assert len(config.mcp.shttp_servers) == 1
|
||||
# If it's already a proper server config object, just verify it
|
||||
assert isinstance(config.mcp.shttp_servers[0], MCPSHTTPServerConfig)
|
||||
assert config.mcp.shttp_servers[0].url == 'http://env-server:8080'
|
||||
assert config.mcp.shttp_servers[0].api_key == 'env-api-key'
|
||||
|
||||
# Create a session with the config
|
||||
session = Session(
|
||||
sid='test-sid',
|
||||
file_store=InMemoryFileStore({}),
|
||||
config=config,
|
||||
sio=AsyncMock(),
|
||||
)
|
||||
|
||||
# Create empty settings
|
||||
settings = ConversationInitData()
|
||||
|
||||
# Mock the Agent.get_cls method to avoid AgentNotRegisteredError
|
||||
mock_agent_cls = MagicMock()
|
||||
mock_agent_instance = MagicMock()
|
||||
mock_agent_cls.return_value = mock_agent_instance
|
||||
|
||||
# Initialize the agent (this is where the MCP config would be reset)
|
||||
with (
|
||||
patch.object(session.agent_session, 'start', AsyncMock()),
|
||||
patch.object(Agent, 'get_cls', return_value=mock_agent_cls),
|
||||
):
|
||||
await session.initialize_agent(settings, None, None)
|
||||
|
||||
# Verify that the MCP configuration was preserved
|
||||
assert len(session.config.mcp.shttp_servers) >= 0
|
||||
|
||||
# Clean up
|
||||
await session.close()
|
||||
|
||||
@@ -70,21 +70,7 @@ async def test_mixed_connection_results():
|
||||
|
||||
# Create a successful client
|
||||
successful_client = mock.MagicMock(spec=MCPClient)
|
||||
|
||||
# Create a mock tool with a to_param method that returns a tool dictionary
|
||||
mock_tool = mock.MagicMock()
|
||||
mock_tool.name = 'mock_tool'
|
||||
mock_tool.to_param.return_value = {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'mock_tool',
|
||||
'description': 'A mock tool for testing',
|
||||
'parameters': {},
|
||||
},
|
||||
}
|
||||
|
||||
# Set the client's tools
|
||||
successful_client.tools = [mock_tool]
|
||||
successful_client.tools = [mock.MagicMock()]
|
||||
|
||||
# Mock create_mcp_clients to return our successful client
|
||||
with mock.patch(
|
||||
@@ -95,4 +81,3 @@ async def test_mixed_connection_results():
|
||||
|
||||
# Verify that tools were returned
|
||||
assert len(tools) > 0
|
||||
assert tools[0]['function']['name'] == 'mock_tool'
|
||||
|
||||
Reference in New Issue
Block a user