From ade059bfba11ba5ec8062d03117410a4eec04af0 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Sat, 10 May 2025 03:45:33 +0400 Subject: [PATCH] feat/fix(fontend): Get public repos via repo URL (#8223) Co-authored-by: Robert Brennan Co-authored-by: rohitvinodmalhotra@gmail.com --- .../features/git/git-repo-selector.test.tsx | 89 ------ .../home/repo-selection-form.test.tsx | 259 ++++++++++++++++++ .../features/git/git-repo-selector.tsx | 203 -------------- .../git/git-repositories-suggestion-box.tsx | 77 ------ .../home/repo-selection-form.test.tsx | 18 +- .../features/home/repo-selection-form.tsx | 24 +- .../repository-dropdown.tsx | 3 + .../settings/settings-dropdown-input.tsx | 3 + .../hooks/query/use-search-repositories.ts | 1 - .../integrations/github/github_service.py | 1 + .../integrations/gitlab/gitlab_service.py | 1 + openhands/integrations/provider.py | 3 +- 12 files changed, 304 insertions(+), 378 deletions(-) delete mode 100644 frontend/__tests__/components/features/git/git-repo-selector.test.tsx create mode 100644 frontend/__tests__/components/features/home/repo-selection-form.test.tsx delete mode 100644 frontend/src/components/features/git/git-repo-selector.tsx delete mode 100644 frontend/src/components/features/git/git-repositories-suggestion-box.tsx diff --git a/frontend/__tests__/components/features/git/git-repo-selector.test.tsx b/frontend/__tests__/components/features/git/git-repo-selector.test.tsx deleted file mode 100644 index 6a982a5697..0000000000 --- a/frontend/__tests__/components/features/git/git-repo-selector.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { GitRepositorySelector } from "#/components/features/git/git-repo-selector"; -import OpenHands from "#/api/open-hands"; -import { Provider } from "#/types/settings"; - -describe("GitRepositorySelector", () => { - const onInputChangeMock = vi.fn(); - const onSelectMock = vi.fn(); - - it("should render the search input", () => { - renderWithProviders( - , - ); - - expect( - screen.getByPlaceholderText("LANDING$SELECT_GIT_REPO"), - ).toBeInTheDocument(); - }); - - it("should show the GitHub login button in OSS mode", () => { - const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); - getConfigSpy.mockResolvedValue({ - APP_MODE: "oss", - APP_SLUG: "openhands", - GITHUB_CLIENT_ID: "test-client-id", - POSTHOG_CLIENT_KEY: "test-posthog-key", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: false, - }, - }); - - renderWithProviders( - , - ); - - expect(screen.getByTestId("github-repo-selector")).toBeInTheDocument(); - }); - - it("should show the search results", () => { - const mockSearchedRepos = [ - { - id: 1, - full_name: "test/repo1", - git_provider: "github" as Provider, - stargazers_count: 100, - is_public: true, - pushed_at: "2023-01-01T00:00:00Z", - }, - { - id: 2, - full_name: "test/repo2", - git_provider: "github" as Provider, - stargazers_count: 200, - is_public: true, - pushed_at: "2023-01-02T00:00:00Z", - }, - ]; - - const searchPublicRepositoriesSpy = vi.spyOn( - OpenHands, - "searchGitRepositories", - ); - searchPublicRepositoriesSpy.mockResolvedValue(mockSearchedRepos); - - renderWithProviders( - , - ); - - expect(screen.getByTestId("github-repo-selector")).toBeInTheDocument(); - }); -}); diff --git a/frontend/__tests__/components/features/home/repo-selection-form.test.tsx b/frontend/__tests__/components/features/home/repo-selection-form.test.tsx new file mode 100644 index 0000000000..86a7753734 --- /dev/null +++ b/frontend/__tests__/components/features/home/repo-selection-form.test.tsx @@ -0,0 +1,259 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, vi, beforeEach, it } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import userEvent from "@testing-library/user-event"; +import { RepositorySelectionForm } from "../../../../src/components/features/home/repo-selection-form"; +import OpenHands from "#/api/open-hands"; +import { GitRepository } from "#/types/git"; + +// Create mock functions +const mockUseUserRepositories = vi.fn(); +const mockUseCreateConversation = vi.fn(); +const mockUseIsCreatingConversation = vi.fn(); +const mockUseTranslation = vi.fn(); +const mockUseAuth = vi.fn(); + +// Setup default mock returns +mockUseUserRepositories.mockReturnValue({ + data: [], + isLoading: false, + isError: false, +}); + +mockUseCreateConversation.mockReturnValue({ + mutate: vi.fn(), + isPending: false, + isSuccess: false, +}); + +mockUseIsCreatingConversation.mockReturnValue(false); + +mockUseTranslation.mockReturnValue({ t: (key: string) => key }); + +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(), +}); + +vi.mock("#/hooks/mutation/use-create-conversation", () => ({ + useCreateConversation: () => mockUseCreateConversation(), +})); + +vi.mock("#/hooks/use-is-creating-conversation", () => ({ + useIsCreatingConversation: () => mockUseIsCreatingConversation(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => mockUseTranslation(), +})); + +vi.mock("#/context/auth-context", () => ({ + useAuth: () => mockUseAuth(), +})); + +vi.mock("#/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +const mockOnRepoSelection = vi.fn(); +const renderForm = () => + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + +describe("RepositorySelectionForm", () => { + beforeEach(() => { + 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[] = [ + { + 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(); + expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument(); + }); + + it("shows error message when repository fetch fails", async () => { + const retrieveUserGitRepositoriesSpy = vi.spyOn( + OpenHands, + "retrieveUserGitRepositories", + ); + retrieveUserGitRepositoriesSpy.mockRejectedValue( + new Error("Failed to load"), + ); + + renderForm(); + + expect( + await screen.findByTestId("repo-dropdown-error"), + ).toBeInTheDocument(); + expect( + screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"), + ).toBeInTheDocument(); + }); + + it("should call the search repos API when searching a URL", async () => { + 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 MOCK_SEARCH_REPOS: GitRepository[] = [ + { + id: 3, + full_name: "kubernetes/kubernetes", + git_provider: "github", + is_public: true, + }, + ]; + + const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories"); + const retrieveUserGitRepositoriesSpy = vi.spyOn( + OpenHands, + "retrieveUserGitRepositories", + ); + + searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS); + retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS); + + renderForm(); + + 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 () => { + const MOCK_SEARCH_REPOS: GitRepository[] = [ + { + id: 3, + full_name: "kubernetes/kubernetes", + git_provider: "github", + is_public: true, + }, + ]; + + const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories"); + searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS); + + renderForm(); + + 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].full_name, + ); + }); +}); diff --git a/frontend/src/components/features/git/git-repo-selector.tsx b/frontend/src/components/features/git/git-repo-selector.tsx deleted file mode 100644 index 6e82f455fc..0000000000 --- a/frontend/src/components/features/git/git-repo-selector.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { - Autocomplete, - AutocompleteItem, - AutocompleteSection, - Spinner, -} from "@heroui/react"; -import { useDispatch } from "react-redux"; -import posthog from "posthog-js"; -import { I18nKey } from "#/i18n/declaration"; -import { setSelectedRepository } from "#/state/initial-query-slice"; -import { useConfig } from "#/hooks/query/use-config"; -import { sanitizeQuery } from "#/utils/sanitize-query"; -import { GitRepository } from "#/types/git"; -import { Provider, ProviderOptions } from "#/types/settings"; - -interface GitRepositorySelectorProps { - onInputChange: (value: string) => void; - onSelect: () => void; - userRepositories: GitRepository[]; - publicRepositories: GitRepository[]; - isLoading?: boolean; -} - -export function GitRepositorySelector({ - onInputChange, - onSelect, - userRepositories, - publicRepositories, - isLoading = false, -}: GitRepositorySelectorProps) { - const { t } = useTranslation(); - const { data: config } = useConfig(); - const [selectedKey, setSelectedKey] = React.useState(null); - - const allRepositories: GitRepository[] = [ - ...publicRepositories.filter( - (repo) => !userRepositories.find((r) => r.id === repo.id), - ), - ...userRepositories, - ]; - - // Group repositories by provider - const groupedUserRepos = userRepositories.reduce< - Record - >( - (acc, repo) => { - if (!acc[repo.git_provider]) { - acc[repo.git_provider] = []; - } - acc[repo.git_provider].push(repo); - return acc; - }, - {} as Record, - ); - - const groupedPublicRepos = publicRepositories.reduce< - Record - >( - (acc, repo) => { - if (!acc[repo.git_provider]) { - acc[repo.git_provider] = []; - } - acc[repo.git_provider].push(repo); - return acc; - }, - {} as Record, - ); - - const dispatch = useDispatch(); - - const handleRepoSelection = (id: string | null) => { - const repo = allRepositories.find((r) => r.id.toString() === id); - if (repo) { - dispatch(setSelectedRepository(repo)); - posthog.capture("repository_selected"); - onSelect(); - setSelectedKey(id); - } - }; - - const handleClearSelection = () => { - dispatch(setSelectedRepository(null)); - }; - - const emptyContent = isLoading ? ( -
- - {t(I18nKey.GITHUB$LOADING_REPOSITORIES)} -
- ) : ( - t(I18nKey.GITHUB$NO_RESULTS) - ); - - return ( - : undefined, - }} - onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)} - onInputChange={onInputChange} - clearButtonProps={{ onPress: handleClearSelection }} - listboxProps={{ - emptyContent, - }} - defaultFilter={(textValue, inputValue) => { - if (!inputValue) return true; - - const sanitizedInput = sanitizeQuery(inputValue); - - const repo = allRepositories.find((r) => r.full_name === textValue); - if (!repo) return false; - - const provider = repo.git_provider?.toLowerCase() as Provider; - const providerKeys = Object.keys(ProviderOptions) as Provider[]; - - // If input is exactly "git", show repos from any git-based provider - if (sanitizedInput === "git") { - return providerKeys.includes(provider); - } - - // Provider based typeahead - for (const p of providerKeys) { - if (p.startsWith(sanitizedInput)) { - return provider === p; - } - } - - // Default case: check if the repository name matches the input - return sanitizeQuery(textValue).includes(sanitizedInput); - }} - > - {config?.APP_MODE === "saas" && - config?.APP_SLUG && - (( - - e.stopPropagation()} - > - {t(I18nKey.GITHUB$ADD_MORE_REPOS)} - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) as any)} - {Object.entries(groupedUserRepos).map(([provider, repos]) => - repos.length > 0 ? ( - - {repos.map((repo) => ( - - {repo.full_name} - - ))} - - ) : null, - )} - {Object.entries(groupedPublicRepos).map(([provider, repos]) => - repos.length > 0 ? ( - - {repos.map((repo) => ( - - {repo.full_name} - - ({repo.stargazers_count || 0}⭐) - - - ))} - - ) : null, - )} - - ); -} diff --git a/frontend/src/components/features/git/git-repositories-suggestion-box.tsx b/frontend/src/components/features/git/git-repositories-suggestion-box.tsx deleted file mode 100644 index d86f2fd407..0000000000 --- a/frontend/src/components/features/git/git-repositories-suggestion-box.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; -import { I18nKey } from "#/i18n/declaration"; -import { SuggestionBox } from "#/components/features/suggestions/suggestion-box"; -import { GitRepositorySelector } from "./git-repo-selector"; -import { useSearchRepositories } from "#/hooks/query/use-search-repositories"; -import { useUserRepositories } from "#/hooks/query/use-user-repositories"; -import { sanitizeQuery } from "#/utils/sanitize-query"; -import { useDebounce } from "#/hooks/use-debounce"; -import { BrandButton } from "../settings/brand-button"; -import GitHubLogo from "#/assets/branding/github-logo.svg?react"; -import { GitHubErrorReponse, GitUser } from "#/types/git"; - -interface GitRepositoriesSuggestionBoxProps { - handleSubmit: () => void; - gitHubAuthUrl: string | null; - user: GitHubErrorReponse | GitUser | null; -} - -export function GitRepositoriesSuggestionBox({ - handleSubmit, - gitHubAuthUrl, - user, -}: GitRepositoriesSuggestionBoxProps) { - const { t } = useTranslation(); - const navigate = useNavigate(); - const [searchQuery, setSearchQuery] = React.useState(""); - - const debouncedSearchQuery = useDebounce(searchQuery, 300); - - // TODO: Use `useQueries` to fetch all repositories in parallel - const { data: userRepositories, isLoading: isUserReposLoading } = - useUserRepositories(); - const { data: searchedRepos, isLoading: isSearchReposLoading } = - useSearchRepositories(sanitizeQuery(debouncedSearchQuery)); - - const isLoading = isUserReposLoading || isSearchReposLoading; - - const handleConnectToGitHub = () => { - if (gitHubAuthUrl) { - window.location.href = gitHubAuthUrl; - } else { - navigate("/settings"); - } - }; - - const isLoggedIn = !!user; - - return ( - - ) : ( - } - > - {t(I18nKey.GITHUB$CONNECT)} - - ) - } - /> - ); -} diff --git a/frontend/src/components/features/home/repo-selection-form.test.tsx b/frontend/src/components/features/home/repo-selection-form.test.tsx index 4a760bf506..f97d592035 100644 --- a/frontend/src/components/features/home/repo-selection-form.test.tsx +++ b/frontend/src/components/features/home/repo-selection-form.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react"; import { describe, test, expect, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RepositorySelectionForm } from "./repo-selection-form"; // Create mock functions @@ -74,9 +75,16 @@ vi.mock("#/context/auth-context", () => ({ useAuth: () => mockUseAuth(), })); -describe("RepositorySelectionForm", () => { - const mockOnRepoSelection = vi.fn(); +const renderRepositorySelectionForm = () => + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); +describe("RepositorySelectionForm", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -89,7 +97,7 @@ describe("RepositorySelectionForm", () => { isError: false, }); - render(); + renderRepositorySelectionForm(); // Check if loading indicator is displayed expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument(); @@ -117,7 +125,7 @@ describe("RepositorySelectionForm", () => { isError: false, }); - render(); + renderRepositorySelectionForm(); // Check if dropdown is displayed expect(screen.getByTestId("repo-dropdown")).toBeInTheDocument(); @@ -132,7 +140,7 @@ describe("RepositorySelectionForm", () => { error: new Error("Failed to fetch repositories"), }); - render(); + renderRepositorySelectionForm(); // Check if error message is displayed expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument(); diff --git a/frontend/src/components/features/home/repo-selection-form.tsx b/frontend/src/components/features/home/repo-selection-form.tsx index c2684cb113..2bc870eadd 100644 --- a/frontend/src/components/features/home/repo-selection-form.tsx +++ b/frontend/src/components/features/home/repo-selection-form.tsx @@ -6,6 +6,9 @@ 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 { useSearchRepositories } from "#/hooks/query/use-search-repositories"; +import { useDebounce } from "#/hooks/use-debounce"; +import { sanitizeQuery } from "#/utils/sanitize-query"; import { RepositoryDropdown, RepositoryLoadingState, @@ -45,6 +48,10 @@ export function RepositorySelectionForm({ const isCreatingConversationElsewhere = useIsCreatingConversation(); const { t } = useTranslation(); + const [searchQuery, setSearchQuery] = React.useState(""); + const debouncedSearchQuery = useDebounce(searchQuery, 300); + const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery); + // Auto-select main or master branch if it exists React.useEffect(() => { if ( @@ -71,7 +78,8 @@ export function RepositorySelectionForm({ const isCreatingConversation = isPending || isSuccess || isCreatingConversationElsewhere; - const repositoriesItems = repositories?.map((repo) => ({ + const allRepositories = repositories?.concat(searchedRepos || []); + const repositoriesItems = allRepositories?.map((repo) => ({ key: repo.id, label: repo.full_name, })); @@ -82,7 +90,7 @@ export function RepositorySelectionForm({ })); const handleRepoSelection = (key: React.Key | null) => { - const selectedRepo = repositories?.find( + const selectedRepo = allRepositories?.find( (repo) => repo.id.toString() === key, ); @@ -101,6 +109,9 @@ export function RepositorySelectionForm({ setSelectedRepository(null); setSelectedBranch(null); onRepoSelection(null); + } else if (value.startsWith("https://")) { + const repoName = sanitizeQuery(value); + setSearchQuery(repoName); } }; @@ -125,6 +136,15 @@ export function RepositorySelectionForm({ 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); + }} /> ); }; diff --git a/frontend/src/components/features/home/repository-selection/repository-dropdown.tsx b/frontend/src/components/features/home/repository-selection/repository-dropdown.tsx index d28ebc088f..03aaddd6cc 100644 --- a/frontend/src/components/features/home/repository-selection/repository-dropdown.tsx +++ b/frontend/src/components/features/home/repository-selection/repository-dropdown.tsx @@ -5,12 +5,14 @@ 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) { return ( ); } diff --git a/frontend/src/components/features/settings/settings-dropdown-input.tsx b/frontend/src/components/features/settings/settings-dropdown-input.tsx index 0b000e3f45..14bee3eba5 100644 --- a/frontend/src/components/features/settings/settings-dropdown-input.tsx +++ b/frontend/src/components/features/settings/settings-dropdown-input.tsx @@ -17,6 +17,7 @@ interface SettingsDropdownInputProps { isClearable?: boolean; onSelectionChange?: (key: React.Key | null) => void; onInputChange?: (value: string) => void; + defaultFilter?: (textValue: string, inputValue: string) => boolean; } export function SettingsDropdownInput({ @@ -33,6 +34,7 @@ export function SettingsDropdownInput({ isClearable, onSelectionChange, onInputChange, + defaultFilter, }: SettingsDropdownInputProps) { return (